diff --git a/application/single_app/config.py b/application/single_app/config.py index aaf7ab33f..14e7f949f 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -101,7 +101,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.261.313" +VERSION = "0.261.314" IS_DEVELOPMENT = is_development_env_enabled() # Opt-out for deployments where App Service Easy Auth is active but the platform diff --git a/application/single_app/functions_azure_endpoint_validation.py b/application/single_app/functions_azure_endpoint_validation.py index 3ab64a4f5..a8b38f5ad 100644 --- a/application/single_app/functions_azure_endpoint_validation.py +++ b/application/single_app/functions_azure_endpoint_validation.py @@ -86,6 +86,10 @@ "Queue Storage actions require an HTTPS Azure Queue service endpoint such as " "https://account.queue.core.windows.net" ) +AZURE_FILE_ENDPOINT_ERROR = ( + "Azure Files sources require an HTTPS Azure File service endpoint such as " + "https://account.file.core.windows.net" +) AZURE_COSMOS_ENDPOINT_ERROR = ( "Cosmos actions require an HTTPS Azure Cosmos DB endpoint such as " "https://account.documents.azure.com" @@ -243,6 +247,11 @@ def validate_azure_queue_endpoint(value: Any) -> str: return _validate_storage_endpoint(value, AZURE_QUEUE_SERVICE_LABEL, AZURE_QUEUE_ENDPOINT_ERROR) +def validate_azure_file_endpoint(value: Any) -> str: + """Return a canonical Azure File service origin, or raise ValueError.""" + return _validate_storage_endpoint(value, AZURE_FILE_SERVICE_LABEL, AZURE_FILE_ENDPOINT_ERROR) + + def validate_azure_cosmos_endpoint(value: Any) -> str: """Return a canonical Azure Cosmos DB origin, or raise ValueError.""" _, hostname = parse_azure_https_endpoint( diff --git a/application/single_app/functions_file_sync.py b/application/single_app/functions_file_sync.py index f2d202d19..c6c38dc2c 100644 --- a/application/single_app/functions_file_sync.py +++ b/application/single_app/functions_file_sync.py @@ -45,6 +45,7 @@ from functions_azure_endpoint_validation import ( AZURE_STORAGE_ENDPOINT_SUFFIXES, azure_storage_endpoint_suffix_for_hostname, + validate_azure_file_endpoint, ) from functions_debug import debug_print from functions_documents import ( @@ -892,12 +893,10 @@ def _normalize_azure_file_url(value: Any) -> Tuple[str, List[str]]: if "://" not in raw_url: raw_url = f"https://{raw_url}" + account_url = validate_azure_file_endpoint(raw_url) parsed_url = urlparse(raw_url) - if parsed_url.scheme != "https" or not parsed_url.netloc: - raise ValueError("Azure Files sources require an HTTPS file service or share URL") - path_parts = [unquote(path_part) for path_part in parsed_url.path.split("/") if path_part] - return f"{parsed_url.scheme}://{parsed_url.netloc}".rstrip("/"), path_parts + return account_url, path_parts def _normalize_azure_share_name(value: Any) -> str: @@ -3253,9 +3252,11 @@ def _get_azure_files_service_client(source: Dict[str, Any]): raise ValueError("Azure Files connection string authentication requires a connection string") return ShareServiceClient.from_connection_string(connection_string) - account_url = connection.get("account_url") or "" + account_url, account_path_parts = _normalize_azure_file_url(connection.get("account_url")) if not account_url: raise ValueError("Azure Files source is missing an account URL") + if account_path_parts: + raise ValueError("Azure Files source account URL must be a service URL") if auth_type == "client_secret": client_id = auth.get("identity") or "" client_secret = _resolved_auth_secret(auth) diff --git a/docs/explanation/features/v0.241.127/AZURE_FILES_FILE_SYNC.md b/docs/explanation/features/v0.241.127/AZURE_FILES_FILE_SYNC.md index 39d1d9d82..a0afde172 100644 --- a/docs/explanation/features/v0.241.127/AZURE_FILES_FILE_SYNC.md +++ b/docs/explanation/features/v0.241.127/AZURE_FILES_FILE_SYNC.md @@ -6,6 +6,8 @@ Azure Files File Sync adds an Azure Storage file share source type to the existi Implemented in version: **0.241.127** +Endpoint validation updated in version: **0.261.314** + ## Dependencies - `azure-storage-file-share==12.25.0` @@ -24,6 +26,25 @@ Supported reusable workspace identity authentication methods for Azure Files are SMB sources continue to support username/password and anonymous authentication for UNC paths. +### Approved endpoints + +From version **0.261.314**, the File service URL must use a canonical Azure +Files hostname: `account.file.core.windows.net`, +`account.file.core.usgovcloudapi.net`, `account.file.core.chinacloudapi.cn`, or +`account.file.core.cloudapi.de`. A share URL such as +`https://account.file.core.windows.net/documents/team` supplies the share and +optional directory when those fields are not already provided. + +The server rejects arbitrary hosts, IP addresses, embedded credentials, +explicit ports, queries, and fragments, and revalidates saved account URLs +before token-authenticated SDK construction. Private endpoints should use the +canonical account hostname with private DNS. Custom domains, direct private-link +hostnames, and emulator URLs are not accepted in the URL field. +Connection-string credential dispatch is unchanged. + +See [File Sync SSRF endpoint validation](../../fixes/FILE_SYNC_SSRF_ENDPOINT_VALIDATION_FIX.md) +for the security boundary and compatibility details. + ## Usage Instructions Admins can enable Azure Files in Admin Settings under File Sync source type visibility. Workspace managers then choose Azure Files in the Add Source workflow, enter the file service or share URL, share name, and directory path, and select a compatible reusable identity or source-local Azure Files credential. @@ -33,7 +54,13 @@ The managed identity used by the app needs Azure Files data-plane permissions on ## Testing and Validation - Functional coverage: `functional_tests/test_file_sync_azure_files_identity.py` +- Endpoint and OneDrive path regression coverage: `functional_tests/test_file_sync_ssrf_validation.py` - Existing File Sync capability coverage: `functional_tests/test_file_sync_capability.py` - UI coverage updated for source type visibility and Azure Files source modal rendering -Known limitation: PIV/smart-card authentication is not implemented inside the web app. PIV and smart-card flows belong at the client or domain/Kerberos layer for SMB access; SimpleChat can consume the resulting SMB path through the existing SMB connector. \ No newline at end of file +Known limitation: PIV/smart-card authentication is not implemented inside the web app. PIV and smart-card flows belong at the client or domain/Kerberos layer for SMB access; SimpleChat can consume the resulting SMB path through the existing SMB connector. + +Known limitation: the pinned Azure Files SDK requires `token_intent` for token +credentials, but the current connector does not supply it. Managed-identity and +service-principal client construction therefore fails before network access. +The endpoint validation change does not repair this separate authentication issue. \ No newline at end of file diff --git a/docs/explanation/fixes/FILE_SYNC_SSRF_ENDPOINT_VALIDATION_FIX.md b/docs/explanation/fixes/FILE_SYNC_SSRF_ENDPOINT_VALIDATION_FIX.md new file mode 100644 index 000000000..3ac2346d2 --- /dev/null +++ b/docs/explanation/fixes/FILE_SYNC_SSRF_ENDPOINT_VALIDATION_FIX.md @@ -0,0 +1,123 @@ +# File Sync SSRF Endpoint Validation Fix + +Fixed in version: **0.261.314** + +Related code-scanning alerts: [1090](https://github.com/microsoft/simplechat/security/code-scanning/1090) +and [2098](https://github.com/microsoft/simplechat/security/code-scanning/2098), both +`py/partial-ssrf`. + +## Issue description and root cause + +Azure Files connection normalization previously required only an HTTPS scheme +and a nonempty authority. Arbitrary hosts could consequently reach the +`ShareServiceClient` constructor with the application's managed identity or a +service-principal credential. Validating a URL's syntax alone does not establish +that its destination is an approved Azure service. + +The pinned `azure-storage-file-share==12.25.0` SDK currently rejects the affected +token-credential construction because it does not supply `token_intent`. +Investigation did not establish a network request or token disclosure through +that call. This incidental SDK failure is not a durable endpoint security +boundary: fixing authentication separately must not enable arbitrary destinations. +This change does not repair that authentication failure. + +The OneDrive flow reported by alert 1090 already normalizes selected paths, +rejects traversal segments, and percent-encodes each segment beneath a user's +Graph drive path. The reported browse-path input cannot change the Graph origin. +No OneDrive runtime change or alert suppression is included. + +## Technical details + +### Files modified + +- `application/single_app/functions_azure_endpoint_validation.py` +- `application/single_app/functions_file_sync.py` +- `application/single_app/config.py` +- `functional_tests/test_file_sync_ssrf_validation.py` +- [Azure Files feature documentation](../features/v0.241.127/AZURE_FILES_FILE_SYNC.md) +- [File Sync guide](../../guides/create-a-file-sync.md) + +### Endpoint policy + +The shared `validate_azure_file_endpoint()` validator requires the `file` service +label, a valid 3-24 character alphanumeric storage account name, and a supported +Azure Storage suffix: + +| Cloud | File service hostname | +| --- | --- | +| Public | `account.file.core.windows.net` | +| US Government | `account.file.core.usgovcloudapi.net` | +| China | `account.file.core.chinacloudapi.cn` | +| Germany | `account.file.core.cloudapi.de` | + +The validator rejects arbitrary hosts, IP literals, localhost, embedded +credentials, explicit ports (including 443), query strings, fragments, +parameters, unsupported suffixes, and suffix lookalikes. It reconstructs the +HTTPS origin from the validated account name and suffix rather than passing +through the submitted authority. + +Service URLs, share URLs, and bare canonical hostnames remain accepted during +configuration. Share and directory components are decoded and normalized as +before. Stored account URLs are revalidated before token-credential and SDK +construction and must not contain a share or directory path. + +### Impact and limitations + +Existing sources using canonical Azure File service hostnames remain valid. +Private endpoints should use those same hostnames with private DNS resolution. +Custom domains, Azure Stack/custom suffixes, direct private-link hostnames, and +emulator URLs are rejected for the URL field. + +Connection-string authentication remains unchanged: it uses the supplied +storage credentials, not the alerted token-credential account URL path. This +fix is not a review of all connection-string endpoints or all File Sync +providers. + +OneDrive's absolute Graph pagination links and file-download redirects are not +redesigned by this fix. The regression tests address the reported browse-path +flow, not every possible Graph network interaction. + +## Testing and validation + +`functional_tests/test_file_sync_ssrf_validation.py` executes the production +normalization, SDK-boundary, Graph-path, and browse-dispatch functions using the +existing isolated function loader. External credentials, SDK construction, +source authorization setup, and HTTP are mocked; no Azure or Cosmos bootstrap +or live network access is required. + +Coverage includes accepted endpoints for each supported cloud, decoded +share/directory paths, retained selections, hostile submitted and persisted +URLs, rejection before credential/client construction, canonical SDK inputs, +and unchanged connection-string dispatch. OneDrive tests prepare the outgoing +HTTP URL and verify the configured public/Government Graph origin and user path +are preserved for delimiter-bearing, encoded, and attacker-like browse paths; +invalid segments fail before HTTP. + +The application version in `config.py` is incremented to `0.261.314`. The new +test uses a minimum-version assertion so subsequent version bumps do not break +this regression coverage. + +Before the change, arbitrary HTTPS Azure Files origins passed normalization. +After the change, they fail at configuration and token-client boundaries. +OneDrive's already-protected browse behavior is unchanged. + +Passing local regression tests does not establish CodeQL alert closure. A fresh +GitHub analysis must determine whether either alert remains; neither alert is +automatically dismissed. + +### Local results + +| Command | Result | +| --- | --- | +| `python -m pytest functional_tests\test_file_sync_ssrf_validation.py functional_tests\test_file_sync_azure_files_identity.py functional_tests\test_file_sync_azure_blob_storage.py functional_tests\test_file_sync_onedrive_personal.py -q` | 166 passed, 1 pre-existing failure | +| Same provider command with `-k "not test_file_sync_routes_do_not_disclose_exception_details"` | 166 passed, 1 deselected | +| `python -m pytest functional_tests\test_action_app_identity_endpoint_hardening.py -q -k "endpoint_allowlist or file_sync_reuses_the_shared_allowlist"` | 3 passed, 7 deselected | +| `python functional_tests\test_docs_app_surface_coverage.py` | 7 checks passed; inventory current | +| `python functional_tests\test_docs_site_quality.py` | 6 checks passed | + +The pre-existing failure is +`test_file_sync_routes_do_not_disclose_exception_details`: it expects the old +`[FileSync] Request failed.` tag, while the unchanged route uses +`[FILE_SYNC] Request failed.`. The same failure occurred before the fix +(31 passed, 1 failed). No unrelated logging or test assertion was changed. +The new security regression cases all passed. diff --git a/docs/guides/create-a-file-sync.md b/docs/guides/create-a-file-sync.md index a9a48b245..15c94c6b3 100644 --- a/docs/guides/create-a-file-sync.md +++ b/docs/guides/create-a-file-sync.md @@ -88,6 +88,24 @@ separate workflow; the source editor selects an existing eligible identity. The source appears in the Sync list. Imported files later appear on the workspace **Documents** tab with fixed or folder tags. +## Azure Files endpoint requirements + +From version **0.261.314**, use a canonical Azure Files URL such as +`https://account.file.core.windows.net` or a share URL such as +`https://account.file.core.windows.net/documents/team`. US Government, China, +and Germany endpoints use `core.usgovcloudapi.net`, `core.chinacloudapi.cn`, +and `core.cloudapi.de` respectively after the `account.file.` prefix. + +Do not include credentials, an explicit port, a query string, or a fragment in +the URL. Arbitrary domains, IP addresses, and direct private-link hostnames are +not accepted; private endpoints should resolve the canonical account hostname +through private DNS. + +Endpoint validation does not repair the separate token-authentication +limitation: the current connector's managed-identity and service-principal +client construction is rejected by the pinned Azure Files SDK. Connection-string +dispatch remains unchanged. + ## Troubleshooting | Symptom | Likely cause | Fix | diff --git a/functional_tests/test_file_sync_ssrf_validation.py b/functional_tests/test_file_sync_ssrf_validation.py new file mode 100644 index 000000000..8c14e4b36 --- /dev/null +++ b/functional_tests/test_file_sync_ssrf_validation.py @@ -0,0 +1,304 @@ +# test_file_sync_ssrf_validation.py +""" +Functional tests for File Sync SSRF endpoint and path boundaries. +Version: 0.261.314 +Implemented in: 0.261.314 + +Azure Files must reject unapproved origins before token-credential/client +construction. OneDrive browse paths must remain under the configured Graph +origin. These tests execute production functions using the existing isolated +function loader, without importing Azure/Cosmos bootstrap or making network calls. +They do not establish live Azure connectivity or repair SDK authentication. +""" + +import sys +import types +from unittest.mock import Mock +from urllib.parse import urlparse + +import pytest +import requests + +from test_file_sync_azure_blob_storage import load_functions +from functions_azure_endpoint_validation import ( + AZURE_STORAGE_ENDPOINT_SUFFIXES, + validate_azure_file_endpoint, +) +from test_support.versioning import assert_app_version_at_least + + +HOSTILE_FILE_URLS = ( + "https://attacker.example", + "https://localhost", + "https://127.0.0.1", + "https://169.254.169.254", + "https://[::1]", + "https://[fd00::1]", + "http://account.file.core.windows.net", + "https://account.file.core.windows.net@attacker.example", + "https://user:password@account.file.core.windows.net", + "https://account.file.core.windows.net:443", + "https://account.file.core.windows.net:8443", + "https://account.file.core.windows.net:invalid", + "https://account.file.core.windows.net:99999", + "https://account.file.core.windows.net.evil.example", + "https://account.file.core.windows.net.", + "https://account.privatelink.file.core.windows.net", + "https://account.blob.core.windows.net", + "https://account.queue.core.windows.net", + "https://account.file.custom.example", + "https://ab.file.core.windows.net", + f"https://{'a' * 25}.file.core.windows.net", + "https://bad-account.file.core.windows.net", + "https://bad_account.file.core.windows.net", + "https://extra.account.file.core.windows.net", + "https://account.file.core.windows.net?x=1", + "https://account.file.core.windows.net#fragment", + "https://account.file.core.windows.net/share;parameter", + "https://account.file.core.windows.net\\@attacker.example", + "https://[invalid", +) + + +def file_functions(additional_globals=None): + """Reuse the isolated loader; real validators run with mocked external I/O.""" + namespace = { + "validate_azure_file_endpoint": validate_azure_file_endpoint, + } + namespace.update(additional_globals or {}) + return load_functions( + "functions_file_sync.py", + { + "_normalize_text", + "parse_file_sync_list", + "_normalize_azure_file_url", + "_normalize_azure_share_name", + "_normalize_azure_directory_path", + "_normalize_selected_path", + "_normalize_selected_paths", + "_build_azure_files_url", + "_normalize_azure_files_connection", + "_get_azure_files_service_client", + }, + namespace, + ) + + +def test_implementation_version(): + assert_app_version_at_least("0.261.314") + + +@pytest.mark.parametrize("suffix", AZURE_STORAGE_ENDPOINT_SUFFIXES) +@pytest.mark.parametrize("scheme", ("https://", "")) +def test_azure_files_canonical_service_and_share_urls(suffix, scheme): + functions = file_functions() + host = f"account.file.{suffix}" + service_url = f"https://{host}" + normalized = functions["_normalize_azure_file_url"](f"{scheme}{host.upper()}/") + assert normalized == (service_url, []) + + connection = functions["_normalize_azure_files_connection"]({ + "share_url": f"{scheme}{host.upper()}/documents/Quarterly%20reports/Team", + "selected_paths": ["one.txt", "folder\\two.txt"], + }) + assert connection == { + "account_url": service_url, + "share_name": "documents", + "directory_path": "Quarterly reports/Team", + "share_url": f"{service_url}/documents/Quarterly%20reports/Team", + "selected_paths": ["one.txt", "folder/two.txt"], + } + retained = functions["_normalize_azure_files_connection"]({}, connection) + assert retained == connection + + +@pytest.mark.parametrize("url", HOSTILE_FILE_URLS) +def test_azure_files_rejects_unsafe_submitted_urls(url): + functions = file_functions() + with pytest.raises(ValueError): + validate_azure_file_endpoint(url) + with pytest.raises(ValueError): + functions["_normalize_azure_file_url"](url) + with pytest.raises(ValueError): + functions["_normalize_azure_files_connection"]({ + "account_url": url, + "share_name": "documents", + }) + + +@pytest.fixture +def files_client_boundary(monkeypatch): + sdk_module = types.ModuleType("azure.storage.fileshare") + sdk_module.ShareServiceClient = Mock(name="ShareServiceClient") + monkeypatch.setitem(sys.modules, "azure.storage.fileshare", sdk_module) + managed_credential = Mock(name="DefaultAzureCredential") + secret_credential = Mock(name="ClientSecretCredential") + resolve_secret = Mock(return_value="test-secret") + functions = file_functions({ + "_get_identity_auth_for_source": lambda source: source.get("auth"), + "_resolved_auth_secret": resolve_secret, + "DefaultAzureCredential": managed_credential, + "ClientSecretCredential": secret_credential, + "TENANT_ID": "test-tenant", + }) + return functions, sdk_module.ShareServiceClient, managed_credential, secret_credential, resolve_secret + + +@pytest.mark.parametrize("auth_type", ("managed_identity", "client_secret")) +@pytest.mark.parametrize("url", HOSTILE_FILE_URLS + ( + "", + "https://account.file.core.windows.net/documents", + "https://account.file.core.windows.net/documents/folder", +)) +def test_azure_files_revalidates_persisted_urls_before_credentials(files_client_boundary, auth_type, url): + functions, client, managed_credential, secret_credential, resolve_secret = files_client_boundary + with pytest.raises(ValueError): + functions["_get_azure_files_service_client"]({ + "connection": {"account_url": url}, + "auth": {"auth_type": auth_type, "identity": "test-client"}, + }) + client.assert_not_called() + managed_credential.assert_not_called() + secret_credential.assert_not_called() + resolve_secret.assert_not_called() + + +@pytest.mark.parametrize("auth_type", ("managed_identity", "client_secret")) +@pytest.mark.parametrize("suffix", AZURE_STORAGE_ENDPOINT_SUFFIXES) +def test_azure_files_sdk_receives_only_canonical_origins(files_client_boundary, auth_type, suffix): + functions, client, managed_credential, secret_credential, resolve_secret = files_client_boundary + result = functions["_get_azure_files_service_client"]({ + "connection": {"account_url": f"https://ACCOUNT.FILE.{suffix.upper()}/"}, + "auth": { + "auth_type": auth_type, + "identity": "test-client", + "tenant_id": "test-tenant", + "managed_identity_client_id": "test-managed-client", + }, + }) + credential_factory = managed_credential if auth_type == "managed_identity" else secret_credential + assert result is client.return_value + client.assert_called_once_with( + account_url=f"https://account.file.{suffix}", + credential=credential_factory.return_value, + ) + if auth_type == "managed_identity": + managed_credential.assert_called_once_with(managed_identity_client_id="test-managed-client") + secret_credential.assert_not_called() + resolve_secret.assert_not_called() + else: + secret_credential.assert_called_once_with( + tenant_id="test-tenant", client_id="test-client", client_secret="test-secret", + ) + managed_credential.assert_not_called() + + +def test_azure_files_connection_string_dispatch_is_unchanged(files_client_boundary): + functions, client, managed_credential, secret_credential, resolve_secret = files_client_boundary + result = functions["_get_azure_files_service_client"]({ + "connection": {}, + "auth": {"auth_type": "connection_string"}, + }) + assert result is client.from_connection_string.return_value + client.from_connection_string.assert_called_once_with("test-secret") + client.assert_not_called() + managed_credential.assert_not_called() + secret_credential.assert_not_called() + resolve_secret.assert_called_once() + + +def onedrive_functions(graph_base): + """Execute Graph path building and the browse dispatch with a mocked HTTP sink.""" + prepared_requests = [] + response = Mock(status_code=200) + response.json.return_value = {"value": []} + + def get(url, **kwargs): + prepared = requests.Request( + "GET", url, headers=kwargs.get("headers"), params=kwargs.get("params"), + ).prepare() + prepared_requests.append(prepared) + return response + + graph_functions = load_functions( + "functions_authentication.py", + {"get_graph_endpoint"}, + {"get_graph_base_url": lambda: graph_base}, + ) + source = { + "scope_type": "personal", + "user_id": "authenticated-user", + "source_type": "onedrive", + } + functions = load_functions( + "functions_file_sync.py", + { + "_normalize_text", "_normalize_selected_path", + "_validate_scope", "_scope_field", "_source_scope_id", + "_quote_graph_path", "_onedrive_user_path", + "_onedrive_children_path", "_iter_onedrive_children", + "_browse_onedrive_path", "_graph_get_json", "browse_file_sync_source_path", + }, + { + "requests": types.SimpleNamespace(get=Mock(side_effect=get)), + "get_graph_endpoint": graph_functions["get_graph_endpoint"], + "_onedrive_headers": lambda: {"Accept": "application/json"}, + "_build_connection_test_source": lambda *args, **kwargs: source, + "FILE_SYNC_SCOPE_PERSONAL": "personal", + "FILE_SYNC_SCOPE_GROUP": "group", + "FILE_SYNC_SCOPE_PUBLIC": "public", + "FILE_SYNC_SCOPES": {"personal", "group", "public"}, + "FILE_SYNC_SOURCE_TYPE_ONEDRIVE": "onedrive", + }, + ) + return functions, prepared_requests + + +@pytest.mark.parametrize("graph_base", ( + "https://graph.microsoft.com/v1.0", + "https://graph.microsoft.us/v1.0", +)) +@pytest.mark.parametrize("browse_path, encoded_path", ( + ("", ""), + ("Quarterly reports/notes.txt", "Quarterly%20reports/notes.txt"), + ("notes?x=1#fragment", "notes%3Fx%3D1%23fragment"), + ("%2e%2e/%2f%2fevil.example", "%252e%252e/%252f%252fevil.example"), + ("//evil.example/folder", "evil.example/folder"), + ("https:/evil.example/folder", "https%3A/evil.example/folder"), + ("name:/children", "name%3A/children"), + ("folder\\notes.txt", "folder/notes.txt"), +)) +def test_onedrive_browse_paths_cannot_change_graph_origin(graph_base, browse_path, encoded_path): + functions, prepared_requests = onedrive_functions(graph_base) + result = functions["browse_file_sync_source_path"]( + "personal", "authenticated-user", + {"browse_path": browse_path, "user_id": "attacker-supplied-user"}, + "authenticated-user", + ) + assert result["success"] is True + assert len(prepared_requests) == 1 + prepared_url = prepared_requests[0].url + parsed = urlparse(prepared_url) + assert parsed.scheme == "https" + assert parsed.netloc == urlparse(graph_base).netloc + suffix = f"/root:/{encoded_path}:/children" if encoded_path else "/root/children" + assert prepared_url.split("?")[0] == f"{graph_base}/users/authenticated-user/drive{suffix}" + assert not parsed.fragment + + +@pytest.mark.parametrize("browse_path", ( + "..", "../folder", "folder/..", "./folder", "folder/./item", + "folder//item", "folder\\..\\item", "https://evil.example/folder", +)) +def test_onedrive_invalid_browse_paths_fail_before_http(browse_path): + functions, prepared_requests = onedrive_functions("https://graph.microsoft.com/v1.0") + with pytest.raises(ValueError, match="inside the configured source root"): + functions["browse_file_sync_source_path"]( + "personal", "authenticated-user", {"browse_path": browse_path}, "authenticated-user", + ) + functions["requests"].get.assert_not_called() + assert prepared_requests == [] + + +if __name__ == "__main__": + sys.exit(pytest.main([__file__, "-q"]))