diff --git a/application/single_app/config.py b/application/single_app/config.py index bfeb46dae..1c3ddd3ae 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -34,6 +34,7 @@ import pandas from functions_latest_features_nav import is_development_env_enabled from functions_appinsights import log_event +from functions_azure_endpoint_validation import validate_azure_blob_endpoint from functions_environment import load_simplechat_dotenv from flask import ( @@ -96,7 +97,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.260.028" +VERSION = "0.260.029" IS_DEVELOPMENT = is_development_env_enabled() SESSION_COOKIE_SAMESITE = os.getenv('SESSION_COOKIE_SAMESITE', 'Lax') @@ -519,7 +520,9 @@ def build_enhanced_citations_blob_service_client(settings): blob_endpoint = str(settings.get("office_docs_storage_account_blob_endpoint") or "").strip() if not blob_endpoint: raise ValueError("Enhanced Citations blob endpoint is required for managed identity authentication.") - return BlobServiceClient(account_url=blob_endpoint, credential=DefaultAzureCredential()) + safe_blob_endpoint = validate_azure_blob_endpoint(blob_endpoint) + # codeql[py/full-ssrf] + return BlobServiceClient(account_url=safe_blob_endpoint, credential=DefaultAzureCredential()) connection_string = str(settings.get("office_docs_storage_account_url") or "").strip() if not connection_string: diff --git a/application/single_app/functions_action_connection_tests.py b/application/single_app/functions_action_connection_tests.py index dce1dc27d..b31036d07 100644 --- a/application/single_app/functions_action_connection_tests.py +++ b/application/single_app/functions_action_connection_tests.py @@ -20,6 +20,7 @@ import requests from functions_appinsights import log_event +from functions_azure_endpoint_validation import validate_azure_maps_endpoint from functions_azure_maps import ( AZURE_MAPS_DEFAULT_ENDPOINT, AZURE_MAPS_DEFAULT_LANGUAGE, @@ -28,6 +29,7 @@ AZURE_MAPS_TILE_API_VERSION, ) from functions_mcp_operations import MCP_CUSTOM_HEADERS_FIELD +from functions_outbound_http import OutboundHttpPolicyError, request_public_https ACTION_CONNECTION_TEST_MAX_TIMEOUT_SECONDS = 20 ACTION_CONNECTION_TEST_MIN_TIMEOUT_SECONDS = 1 @@ -228,15 +230,15 @@ def test_openapi_connection(manifest: Dict[str, Any]) -> Dict[str, Any]: probe = _build_openapi_probe_request(manifest) try: - response = requests.get( + response = request_public_https( + "GET", base_url, headers=probe["headers"], params=probe["params"] or None, auth=probe["basic_auth"], timeout=timeout, - allow_redirects=True, ) - except requests.RequestException as exc: + except (requests.RequestException, OutboundHttpPolicyError) as exc: result = build_failure_result( f"The API base URL could not be reached: {sanitize_connection_error(exc, manifest)}", status=502, @@ -271,12 +273,17 @@ def test_azure_maps_connection(manifest: Dict[str, Any]) -> Dict[str, Any]: """Validate an Azure Maps action by fetching a single base road tile.""" auth = manifest.get("auth") if isinstance(manifest.get("auth"), dict) else {} subscription_key = str(auth.get("key") or "").strip() - endpoint = str(manifest.get("endpoint") or AZURE_MAPS_DEFAULT_ENDPOINT).strip().rstrip("/") + raw_endpoint = str(manifest.get("endpoint") or AZURE_MAPS_DEFAULT_ENDPOINT).strip() if not subscription_key: return build_failure_result("An Azure Maps subscription key is required before testing this action.") + try: + endpoint = validate_azure_maps_endpoint(raw_endpoint) + except ValueError as exc: + return build_failure_result(str(exc)) try: + # codeql[py/partial-ssrf] response = requests.get( f"{endpoint}/map/tile", params={ @@ -291,6 +298,7 @@ def test_azure_maps_connection(manifest: Dict[str, Any]) -> Dict[str, Any]: "subscription-key": subscription_key, }, timeout=ACTION_CONNECTION_TEST_DEFAULT_TIMEOUT_SECONDS, + allow_redirects=False, ) except requests.RequestException as exc: result = build_failure_result( diff --git a/application/single_app/functions_azure_endpoint_validation.py b/application/single_app/functions_azure_endpoint_validation.py index 1fbf7cd2c..80f43f58c 100644 --- a/application/single_app/functions_azure_endpoint_validation.py +++ b/application/single_app/functions_azure_endpoint_validation.py @@ -15,7 +15,7 @@ import ipaddress import re from typing import Any, Iterable, Tuple -from urllib.parse import ParseResult, urlparse +from urllib.parse import ParseResult, quote, unquote, urlparse # Azure Storage service suffixes for the public, US Government, China, and Germany clouds. AZURE_STORAGE_ENDPOINT_SUFFIXES = ( @@ -47,6 +47,17 @@ "login.chinacloudapi.cn", "login.microsoftonline.de", ) +AZURE_FOUNDRY_ENDPOINT_SUFFIXES = ( + "services.ai.azure.com", + "services.ai.azure.us", + "services.ai.azure.cn", + "services.ai.azure.de", +) +AZURE_MAPS_ENDPOINT_HOSTS = ( + "atlas.microsoft.com", + "atlas.azure.us", + "atlas.azure.cn", +) AZURE_BLOB_SERVICE_LABEL = "blob" AZURE_QUEUE_SERVICE_LABEL = "queue" @@ -58,6 +69,8 @@ DNS_NAME_PATTERN = re.compile( r"^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$" ) +KEY_VAULT_NAME_PATTERN = re.compile(r"^[a-z][a-z0-9-]{1,22}[a-z0-9]$") +FOUNDRY_PROJECT_NAME_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$") AZURE_BLOB_ENDPOINT_ERROR = ( "Blob Storage actions require an HTTPS Azure Blob service endpoint such as " @@ -83,6 +96,21 @@ "Log Analytics actions require a supported Microsoft Entra authority host such as " "login.microsoftonline.com" ) +AZURE_FILE_ENDPOINT_ERROR = ( + "Azure Files requires an HTTPS Azure File service endpoint such as " + "https://account.file.core.windows.net" +) +AZURE_FOUNDRY_ENDPOINT_ERROR = ( + "Foundry requires an HTTPS Azure AI Foundry endpoint such as " + "https://resource.services.ai.azure.com" +) +AZURE_MAPS_ENDPOINT_ERROR = ( + "Azure Maps requires a supported HTTPS endpoint such as https://atlas.microsoft.com" +) +AZURE_KEY_VAULT_NAME_ERROR = ( + "Key Vault names must be 3-24 lowercase letters, numbers, or hyphens, start with a letter, " + "and end with a letter or number" +) def _normalize_endpoint_text(value: Any) -> str: @@ -202,6 +230,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 Files 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( @@ -248,3 +281,58 @@ def validate_azure_entra_authority_host(value: Any) -> str: if hostname not in AZURE_ENTRA_AUTHORITY_HOSTS: raise ValueError(AZURE_AUTHORITY_HOST_ERROR) return hostname + + +def validate_azure_foundry_endpoint(value: Any, allow_project_path: bool = False) -> str: + """Return a canonical Azure AI Foundry origin or project endpoint.""" + parsed_url, hostname = parse_azure_https_endpoint(value, AZURE_FOUNDRY_ENDPOINT_ERROR) + resource_name, endpoint_suffix = _match_endpoint_suffix( + hostname, + "", + AZURE_FOUNDRY_ENDPOINT_SUFFIXES, + AZURE_FOUNDRY_ENDPOINT_ERROR, + ) + if not DNS_LABEL_PATTERN.match(resource_name): + raise ValueError(AZURE_FOUNDRY_ENDPOINT_ERROR) + + origin = f"https://{resource_name}.{endpoint_suffix}" + path = parsed_url.path.rstrip("/") + if not path: + return origin + if not allow_project_path: + raise ValueError(AZURE_FOUNDRY_ENDPOINT_ERROR) + + path_parts = [unquote(part) for part in path.split("/") if part] + if ( + len(path_parts) != 3 + or path_parts[:2] != ["api", "projects"] + or not FOUNDRY_PROJECT_NAME_PATTERN.match(path_parts[2]) + ): + raise ValueError(AZURE_FOUNDRY_ENDPOINT_ERROR) + project_name = quote(path_parts[2], safe="-._~") + return f"{origin}/api/projects/{project_name}" + + +def validate_azure_content_understanding_endpoint(value: Any) -> str: + """Return a canonical Azure AI Foundry origin for Content Understanding.""" + return validate_azure_foundry_endpoint(value, allow_project_path=False) + + +def validate_azure_maps_endpoint(value: Any) -> str: + """Return a supported Azure Maps origin, or raise ValueError.""" + _, hostname = parse_azure_https_endpoint(value, AZURE_MAPS_ENDPOINT_ERROR) + if hostname not in AZURE_MAPS_ENDPOINT_HOSTS: + raise ValueError(AZURE_MAPS_ENDPOINT_ERROR) + return f"https://{hostname}" + + +def build_azure_key_vault_endpoint(vault_name: Any, endpoint_suffix: Any) -> str: + """Build a canonical Key Vault origin from a validated name and trusted cloud suffix.""" + normalized_name = str(vault_name or "").strip().lower() + if not KEY_VAULT_NAME_PATTERN.match(normalized_name): + raise ValueError(AZURE_KEY_VAULT_NAME_ERROR) + + normalized_suffix = str(endpoint_suffix or "").strip().lower() + if not normalized_suffix.startswith(".") or not DNS_NAME_PATTERN.match(normalized_suffix[1:]): + raise ValueError("The configured Key Vault endpoint suffix is invalid") + return f"https://{normalized_name}{normalized_suffix}" diff --git a/application/single_app/functions_content_understanding.py b/application/single_app/functions_content_understanding.py index c78098852..90af3481f 100644 --- a/application/single_app/functions_content_understanding.py +++ b/application/single_app/functions_content_understanding.py @@ -15,12 +15,14 @@ import logging import os import time +from urllib.parse import parse_qsl, quote, unquote, urlencode, urlparse, urlunparse import requests from azure.identity import DefaultAzureCredential from config import AZURE_ENVIRONMENT, cognitive_services_scope from functions_appinsights import log_event +from functions_azure_endpoint_validation import validate_azure_content_understanding_endpoint from functions_debug import debug_print import functions_settings @@ -131,18 +133,64 @@ def _validate_config(config): raise ContentUnderstandingNotConfiguredError( "Content Understanding endpoint is not configured." ) + try: + config['endpoint'] = validate_azure_content_understanding_endpoint(config['endpoint']) + except ValueError as error: + raise ContentUnderstandingNotConfiguredError(str(error)) from error def _build_analyze_binary_url(config, analyzer_id, page_range=None): + encoded_analyzer_id = quote(str(analyzer_id or '').strip(), safe='-._~') + query_params = {'api-version': config['api_version']} + if page_range: + query_params['range'] = page_range url = ( - f"{config['endpoint']}/contentunderstanding/analyzers/{analyzer_id}:analyzeBinary" - f"?api-version={config['api_version']}" + f"{config['endpoint']}/contentunderstanding/analyzers/{encoded_analyzer_id}:analyzeBinary" + f"?{urlencode(query_params)}" ) - if page_range: - url = f"{url}&range={page_range}" return url +def _canonicalize_operation_location(operation_location, config): + """Return a same-origin Content Understanding polling URL, or raise.""" + raw_location = str(operation_location or '').strip() + parsed_location = urlparse(raw_location) + parsed_endpoint = urlparse(config['endpoint']) + try: + parsed_port = parsed_location.port + except ValueError as error: + raise ContentUnderstandingError( + "Content Understanding returned an invalid Operation-Location header." + ) from error + + if ( + parsed_location.scheme != 'https' + or parsed_location.hostname != parsed_endpoint.hostname + or parsed_port not in (None, 443) + or parsed_location.username is not None + or parsed_location.password is not None + or parsed_location.fragment + ): + raise ContentUnderstandingError( + "Content Understanding returned an unsafe Operation-Location header." + ) + + path_parts = [part for part in parsed_location.path.split('/') if part] + decoded_parts = [unquote(part) for part in path_parts] + if not path_parts or path_parts[0].lower() != 'contentunderstanding': + raise ContentUnderstandingError( + "Content Understanding returned an unexpected Operation-Location path." + ) + if any(part in {'.', '..'} for part in decoded_parts): + raise ContentUnderstandingError( + "Content Understanding returned an unsafe Operation-Location path." + ) + + safe_path = '/' + '/'.join(quote(part, safe='-._~:') for part in path_parts) + safe_query = urlencode(parse_qsl(parsed_location.query, keep_blank_values=True)) + return urlunparse(('https', parsed_endpoint.netloc, safe_path, '', safe_query, '')) + + def _describe_http_error(response): """Return a readable message for a failed Content Understanding HTTP response.""" detail = '' @@ -202,11 +250,13 @@ def analyze_file_with_content_understanding( + (f" range={page_range}" if page_range else "") ) + # codeql[py/partial-ssrf] response = requests.post( submit_url, headers=headers, data=file_bytes, timeout=CONTENT_UNDERSTANDING_SUBMIT_TIMEOUT_SECONDS, + allow_redirects=False, ) if response.status_code >= 400: @@ -236,6 +286,7 @@ def analyze_file_with_content_understanding( def _poll_analysis_result(operation_location, config, max_wait_seconds): """Poll a Content Understanding operation until it succeeds, fails, or times out.""" poll_headers = _build_auth_headers(config) + poll_url = _canonicalize_operation_location(operation_location, config) start_time = time.time() while True: @@ -247,10 +298,12 @@ def _poll_analysis_result(operation_location, config, max_wait_seconds): time.sleep(CONTENT_UNDERSTANDING_POLL_INTERVAL_SECONDS) + # codeql[py/partial-ssrf] poll_response = requests.get( - operation_location, + poll_url, headers=poll_headers, timeout=CONTENT_UNDERSTANDING_POLL_TIMEOUT_SECONDS, + allow_redirects=False, ) if poll_response.status_code >= 400: raise ContentUnderstandingError(_describe_http_error(poll_response)) @@ -556,8 +609,10 @@ def test_content_understanding_connection(config_override, sample_file_path=None config = _resolve_config(config_override=config_override) - if not config['endpoint']: - return False, "Content Understanding endpoint is required." + try: + _validate_config(config) + except ContentUnderstandingNotConfiguredError as config_error: + return False, str(config_error) if config['authentication_type'] == 'key' and not config['key']: return False, "Content Understanding key is required when key authentication is selected." @@ -566,17 +621,17 @@ def test_content_understanding_connection(config_override, sample_file_path=None except ContentUnderstandingError as auth_error: return False, str(auth_error) - analyzer_id = config['analyzer_id'] - analyzer_url = ( - f"{config['endpoint']}/contentunderstanding/analyzers/{analyzer_id}" - f"?api-version={config['api_version']}" - ) + analyzer_id = quote(config['analyzer_id'], safe='-._~') + analyzer_url = f"{config['endpoint']}/contentunderstanding/analyzers/{analyzer_id}" try: + # codeql[py/partial-ssrf] response = requests.get( analyzer_url, headers=headers, + params={'api-version': config['api_version']}, timeout=CONTENT_UNDERSTANDING_POLL_TIMEOUT_SECONDS, + allow_redirects=False, ) except requests.RequestException as request_error: return False, f"Content Understanding connection error: {request_error}" diff --git a/application/single_app/functions_cosmos_throughput.py b/application/single_app/functions_cosmos_throughput.py index 5ece45c37..aed938e69 100644 --- a/application/single_app/functions_cosmos_throughput.py +++ b/application/single_app/functions_cosmos_throughput.py @@ -838,6 +838,24 @@ def _get_arm_resource_kind(resource_path): return 'unknown' +def _validate_arm_resource_path(resource_path): + """Return a relative Cosmos ARM path that cannot alter the configured ARM origin.""" + normalized_path = str(resource_path or '').strip() + parsed_path = urlparse(normalized_path) + if ( + not normalized_path.startswith('/subscriptions/') + or parsed_path.scheme + or parsed_path.netloc + or parsed_path.params + or parsed_path.query + or parsed_path.fragment + or '\\' in normalized_path + or any(character in normalized_path for character in ('\r', '\n')) + ): + raise CosmosThroughputError('Invalid Cosmos ARM resource path.') + return normalized_path + + def _build_arm_request_context(refresh_id='', resource_kind='unknown'): credential_start = time.perf_counter() _log_refresh_event( @@ -862,7 +880,8 @@ def _build_arm_request_context(refresh_id='', resource_kind='unknown'): def _arm_request(method, resource_path, payload=None, refresh_id='', request_context=None): request_start = time.perf_counter() - resource_kind = _get_arm_resource_kind(resource_path) + safe_resource_path = _validate_arm_resource_path(resource_path) + resource_kind = _get_arm_resource_kind(safe_resource_path) _log_refresh_event( '[CosmosThroughput] ARM request starting.', @@ -873,9 +892,9 @@ def _arm_request(method, resource_path, payload=None, refresh_id='', request_con request_context = request_context or _build_arm_request_context(refresh_id=refresh_id, resource_kind=resource_kind) credential_elapsed_ms = request_context.get('credential_elapsed_ms', 0) - separator = '&' if '?' in resource_path else '?' + separator = '&' if '?' in safe_resource_path else '?' request_url = ( - f"{request_context['resource_manager_endpoint']}{resource_path}" + f"{request_context['resource_manager_endpoint']}{safe_resource_path}" f"{separator}api-version={COSMOS_THROUGHPUT_ARM_API_VERSION}" ) @@ -885,6 +904,8 @@ def _arm_request(method, resource_path, payload=None, refresh_id='', request_con extra={'method': method, 'resource_kind': resource_kind}, ) try: + # The ARM origin is deployment-controlled and the resource path is relative and encoded. + # codeql[py/partial-ssrf] response = requests.request( method, request_url, @@ -894,6 +915,7 @@ def _arm_request(method, resource_path, payload=None, refresh_id='', request_con }, json=payload, timeout=30, + allow_redirects=False, ) except Exception as exc: _log_refresh_event( diff --git a/application/single_app/functions_file_sync.py b/application/single_app/functions_file_sync.py index 11809a96d..a24350fce 100644 --- a/application/single_app/functions_file_sync.py +++ b/application/single_app/functions_file_sync.py @@ -39,8 +39,15 @@ 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_outbound_http import ( + OutboundHttpPolicyError, + normalize_public_https_url, + normalize_same_origin_https_url, + request_public_https, +) from functions_documents import ( allowed_file, create_document, @@ -665,11 +672,10 @@ def _normalize_azure_file_url(value: Any) -> Tuple[str, List[str]]: raw_url = f"https://{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") + account_url = validate_azure_file_endpoint(raw_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: @@ -2618,8 +2624,17 @@ def _onedrive_headers() -> Dict[str, str]: def _graph_get_json(path_or_url: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: - url = path_or_url if str(path_or_url or "").startswith("http") else get_graph_endpoint(path_or_url) - response = requests.get(url, headers=_onedrive_headers(), params=params, timeout=30) + raw_url = str(path_or_url or "").strip() + candidate_url = raw_url if urlparse(raw_url).scheme else get_graph_endpoint(raw_url) + url = normalize_same_origin_https_url(candidate_url, get_graph_base_url()) + # codeql[py/partial-ssrf] + response = requests.get( + url, + headers=_onedrive_headers(), + params=params, + timeout=30, + allow_redirects=False, + ) if response.status_code >= 400: try: error_body = response.json() @@ -2706,7 +2721,7 @@ def _iter_onedrive_children(source: Dict[str, Any], item_id: Optional[str] = Non next_url = _onedrive_children_path(source, item_id=item_id, selected_path=selected_path) items = [] while next_url and len(items) < max_items: - payload = _graph_get_json(next_url, params=params if not str(next_url).startswith("http") else None) + payload = _graph_get_json(next_url, params=params if not urlparse(str(next_url)).scheme else None) items.extend(payload.get("value") or []) next_url = payload.get("@odata.nextLink") return items[:max_items] @@ -2802,7 +2817,26 @@ def _stage_onedrive_file(source: Dict[str, Any], remote_file: Dict[str, Any]) -> temp_dir = "/sc-temp-files" if os.path.exists("/sc-temp-files") else None sha256_hash = hashlib.sha256() download_url = get_graph_endpoint(_onedrive_user_path(source, f"/drive/items/{quote(item_id, safe='')}/content")) - response = requests.get(download_url, headers={"Authorization": f"Bearer {_get_graph_app_token()}"}, stream=True, timeout=120, allow_redirects=True) + response = requests.get( + download_url, + headers={"Authorization": f"Bearer {_get_graph_app_token()}"}, + stream=True, + timeout=120, + allow_redirects=False, + ) + if response.status_code in {301, 302, 303, 307, 308}: + redirect_location = str(response.headers.get("Location") or "").strip() + response.close() + try: + safe_download_url = normalize_public_https_url(redirect_location) + response = request_public_https( + "GET", + safe_download_url, + stream=True, + timeout=120, + ) + except OutboundHttpPolicyError as error: + raise ValueError("OneDrive returned an unsafe file download location") from error if response.status_code >= 400: raise ValueError(f"OneDrive file download failed with {response.status_code}") with tempfile.NamedTemporaryFile(delete=False, suffix=suffix, dir=temp_dir) as temp_file: @@ -2832,6 +2866,7 @@ def _get_azure_files_service_client(source: Dict[str, Any]): account_url = connection.get("account_url") or "" if not account_url: raise ValueError("Azure Files source is missing an account URL") + safe_account_url = validate_azure_file_endpoint(account_url) if auth_type == "client_secret": client_id = auth.get("identity") or "" client_secret = _resolved_auth_secret(auth) @@ -2847,7 +2882,8 @@ def _get_azure_files_service_client(source: Dict[str, Any]): credential = DefaultAzureCredential(managed_identity_client_id=auth.get("managed_identity_client_id") or None) else: raise ValueError("Azure Files sources require managed identity, service principal, or connection string authentication") - return ShareServiceClient(account_url=account_url, credential=credential) + # codeql[py/partial-ssrf] + return ShareServiceClient(account_url=safe_account_url, credential=credential) def _get_azure_files_share_client(source: Dict[str, Any]): diff --git a/application/single_app/functions_keyvault.py b/application/single_app/functions_keyvault.py index fd781587c..7b539c83d 100644 --- a/application/single_app/functions_keyvault.py +++ b/application/single_app/functions_keyvault.py @@ -5,6 +5,7 @@ from datetime import datetime, timezone from urllib.parse import urlparse from functions_appinsights import log_event +from functions_azure_endpoint_validation import build_azure_key_vault_endpoint from config import * from functions_authentication import * from functions_settings import * @@ -78,6 +79,10 @@ KEY_VAULT_SECRET_REMINDER_SYNC_FAILED_STATUS = "sync_failed" KEY_VAULT_SECRET_REMINDER_SYNCED_STATUS = "synced" + +def _build_key_vault_endpoint(vault_name): + return build_azure_key_vault_endpoint(vault_name, KEY_VAULT_DOMAIN) + class SecretReturnType(Enum): VALUE = "value" TRIGGER = "trigger" @@ -120,7 +125,7 @@ def update_key_vault_secret_expiration(secret_name, expires_on): if not validate_secret_name_dynamic(secret_name): raise ValueError("Secret name is not a SimpleChat Key Vault reference.") - key_vault_url = f"https://{key_vault_name}{KEY_VAULT_DOMAIN}" + key_vault_url = _build_key_vault_endpoint(key_vault_name) secret_client = SecretClient(vault_url=key_vault_url, credential=get_keyvault_credential()) secret_client.update_secret_properties( name=secret_name, @@ -719,7 +724,7 @@ def retrieve_secret_from_key_vault_by_full_name(full_secret_name): return full_secret_name try: - key_vault_url = f"https://{key_vault_name}{KEY_VAULT_DOMAIN}" + key_vault_url = _build_key_vault_endpoint(key_vault_name) secret_client = SecretClient(vault_url=key_vault_url, credential=get_keyvault_credential()) retrieved_secret = secret_client.get_secret(full_secret_name) @@ -741,7 +746,7 @@ def resolve_secret_reference_version(full_secret_name): return full_secret_name try: - key_vault_url = f"https://{key_vault_name}{KEY_VAULT_DOMAIN}" + key_vault_url = _build_key_vault_endpoint(key_vault_name) secret_client = SecretClient( vault_url=key_vault_url, credential=get_keyvault_credential(), @@ -780,7 +785,7 @@ def retrieve_secret_from_key_vault_by_reference(secret_reference): try: secret_client = SecretClient( - vault_url=f"https://{key_vault_name}{KEY_VAULT_DOMAIN}", + vault_url=_build_key_vault_endpoint(key_vault_name), credential=get_keyvault_credential(), ) return secret_client.get_secret(path_parts[1], path_parts[2]).value @@ -830,7 +835,7 @@ def retrieve_secret_direct(secret_name, settings=None): raise ValueError("secret_name must not be empty.") try: - key_vault_url = f"https://{key_vault_name}{KEY_VAULT_DOMAIN}" + key_vault_url = _build_key_vault_endpoint(key_vault_name) # Pass settings through so get_keyvault_credential doesn't call the uninitialised cache. secret_client = SecretClient(vault_url=key_vault_url, credential=get_keyvault_credential(settings=settings)) retrieved = secret_client.get_secret(secret_name) @@ -877,7 +882,7 @@ def store_secret_in_key_vault(secret_name, secret_value, scope_value, source="gl full_secret_name = build_full_secret_name(secret_name, scope_value, source, scope) try: - key_vault_url = f"https://{key_vault_name}{KEY_VAULT_DOMAIN}" + key_vault_url = _build_key_vault_endpoint(key_vault_name) secret_client = SecretClient(vault_url=key_vault_url, credential=get_keyvault_credential()) secret_client.set_secret(full_secret_name, secret_value) log_event(f"Secret '{full_secret_name}' stored successfully in Key Vault.", level=logging.INFO) @@ -1417,7 +1422,7 @@ def keyvault_model_endpoint_delete_helper(endpoint_dict, scope_value, scope="glo if not isinstance(auth, dict): return endpoint_dict - key_vault_url = f"https://{key_vault_name}{KEY_VAULT_DOMAIN}" + key_vault_url = _build_key_vault_endpoint(key_vault_name) client = SecretClient(vault_url=key_vault_url, credential=get_keyvault_credential()) for auth_field in MODEL_ENDPOINT_SENSITIVE_AUTH_FIELDS: secret_name = auth.get(auth_field) @@ -1506,7 +1511,7 @@ def keyvault_plugin_delete_helper(plugin_dict, scope_value, scope="global"): ) continue try: - key_vault_url = f"https://{key_vault_name}{KEY_VAULT_DOMAIN}" + key_vault_url = _build_key_vault_endpoint(key_vault_name) log_event(f"Deleting action auth secret '{auth_field}' for action '{plugin_name}' for '{scope}' '{scope_value}'", level=logging.INFO) client = SecretClient(vault_url=key_vault_url, credential=get_keyvault_credential()) client.begin_delete_secret(secret_name) @@ -1537,7 +1542,7 @@ def keyvault_plugin_delete_helper(plugin_dict, scope_value, scope="global"): ) continue try: - key_vault_url = f"https://{key_vault_name}{KEY_VAULT_DOMAIN}" + key_vault_url = _build_key_vault_endpoint(key_vault_name) log_event( f"Deleting MCP custom header secret '{header_name}' for action '{plugin_name}' for '{scope}' '{scope_value}'", level=logging.INFO, @@ -1569,7 +1574,7 @@ def keyvault_plugin_delete_helper(plugin_dict, scope_value, scope="global"): ) continue try: - key_vault_url = f"https://{key_vault_name}{KEY_VAULT_DOMAIN}" + key_vault_url = _build_key_vault_endpoint(key_vault_name) log_event(f"Deleting action additionalField secret '{k}' for action '{plugin_name}' for '{scope}' '{scope_value}'", level=logging.INFO) client = SecretClient(vault_url=key_vault_url, credential=get_keyvault_credential()) client.begin_delete_secret(v) @@ -1615,7 +1620,7 @@ def keyvault_agent_delete_helper(agent_dict, scope_value, scope="global"): if not secret_name or not validate_secret_name_dynamic(secret_name): continue try: - key_vault_url = f"https://{key_vault_name}{KEY_VAULT_DOMAIN}" + key_vault_url = _build_key_vault_endpoint(key_vault_name) log_event(f"Deleting agent secret '{secret_name}' for agent '{agent_name}' for '{scope}' '{scope_value}'", level=logging.INFO) client = SecretClient(vault_url=key_vault_url, credential=get_keyvault_credential()) client.begin_delete_secret(secret_name) diff --git a/application/single_app/functions_outbound_http.py b/application/single_app/functions_outbound_http.py new file mode 100644 index 000000000..6d080ca05 --- /dev/null +++ b/application/single_app/functions_outbound_http.py @@ -0,0 +1,241 @@ +# functions_outbound_http.py +"""Outbound HTTP policy for user-configured public API destinations.""" + +import ipaddress +import socket +from typing import Any, Optional +from urllib.parse import unquote, urljoin, urlsplit, urlunsplit + +import requests + + +OUTBOUND_HTTP_MAX_URL_LENGTH = 4096 +OUTBOUND_HTTP_MAX_REDIRECTS = 5 +OUTBOUND_HTTP_ALLOWED_METHODS = {"DELETE", "GET", "HEAD", "PATCH", "POST", "PUT"} +OUTBOUND_HTTP_BLOCKED_HOSTNAMES = { + "localhost", + "localhost.localdomain", + "metadata.google.internal", +} +OUTBOUND_HTTP_BLOCKED_HOSTNAME_SUFFIXES = ( + ".internal", + ".local", + ".localhost", +) + + +class OutboundHttpPolicyError(ValueError): + """Raised when an outbound destination violates the public HTTPS policy.""" + + +def _normalize_public_hostname(hostname: Any) -> str: + normalized_hostname = str(hostname or "").strip().lower().rstrip(".") + if not normalized_hostname: + raise OutboundHttpPolicyError("Outbound API URLs require a hostname.") + try: + normalized_hostname = normalized_hostname.encode("idna").decode("ascii") + except UnicodeError as error: + raise OutboundHttpPolicyError("Outbound API URLs require a valid hostname.") from error + + if ( + normalized_hostname in OUTBOUND_HTTP_BLOCKED_HOSTNAMES + or any(normalized_hostname.endswith(suffix) for suffix in OUTBOUND_HTTP_BLOCKED_HOSTNAME_SUFFIXES) + or "." not in normalized_hostname + ): + raise OutboundHttpPolicyError("Outbound API URLs cannot target local or internal hostnames.") + + try: + ipaddress.ip_address(normalized_hostname.strip("[]")) + except ValueError: + return normalized_hostname + raise OutboundHttpPolicyError("Outbound API URLs cannot use IP address literals.") + + +def _assert_public_hostname_resolution(hostname: str) -> None: + try: + address_info = socket.getaddrinfo(hostname, 443, type=socket.SOCK_STREAM) + except socket.gaierror as error: + raise OutboundHttpPolicyError("The outbound API hostname could not be resolved.") from error + if not address_info: + raise OutboundHttpPolicyError("The outbound API hostname did not resolve to an address.") + + for address in address_info: + try: + resolved_address = ipaddress.ip_address(address[4][0]) + except ValueError as error: + raise OutboundHttpPolicyError("The outbound API hostname resolved to an invalid address.") from error + if not resolved_address.is_global: + raise OutboundHttpPolicyError( + "Outbound API URLs cannot resolve to private, local, reserved, or metadata addresses." + ) + + +def _url_origin(url: str) -> str: + parsed_url = urlsplit(url) + return f"{parsed_url.scheme}://{parsed_url.hostname}" + + +def normalize_public_https_url( + value: Any, + *, + resolve_dns: bool = True, + required_origin: Optional[str] = None, +) -> str: + """Return a canonical public HTTPS URL, or raise ``OutboundHttpPolicyError``.""" + raw_url = str(value or "").strip() + if not raw_url or len(raw_url) > OUTBOUND_HTTP_MAX_URL_LENGTH: + raise OutboundHttpPolicyError("Outbound API URLs must be between 1 and 4096 characters.") + + parsed_url = urlsplit(raw_url) + try: + parsed_port = parsed_url.port + except ValueError as error: + raise OutboundHttpPolicyError("Outbound API URLs contain an invalid port.") from error + + hostname = _normalize_public_hostname(parsed_url.hostname) + if ( + parsed_url.scheme.lower() != "https" + or parsed_url.username is not None + or parsed_url.password is not None + or parsed_port not in (None, 443) + or parsed_url.fragment + ): + raise OutboundHttpPolicyError( + "Outbound API URLs must use HTTPS port 443 without credentials or fragments." + ) + + if "\\" in raw_url or any(character in raw_url for character in ("\r", "\n", "\x00")): + raise OutboundHttpPolicyError("Outbound API URLs contain unsafe characters.") + + path_parts = [part for part in parsed_url.path.split("/") if part] + decoded_path_parts = [] + for path_part in path_parts: + decoded_part = path_part + for _ in range(3): + next_decoded_part = unquote(decoded_part) + if next_decoded_part == decoded_part: + break + decoded_part = next_decoded_part + decoded_path_parts.append(decoded_part) + if any(part in {".", ".."} for part in decoded_path_parts): + raise OutboundHttpPolicyError("Outbound API URLs cannot contain traversal path segments.") + + normalized_url = urlunsplit(("https", hostname, parsed_url.path or "/", parsed_url.query, "")) + normalized_origin = _url_origin(normalized_url) + if required_origin and normalized_origin != required_origin: + raise OutboundHttpPolicyError("Outbound API redirects cannot change destination origin.") + if resolve_dns: + _assert_public_hostname_resolution(hostname) + return normalized_url + + +def normalize_same_origin_https_url(value: Any, trusted_base_url: Any) -> str: + """Return an HTTPS URL constrained to a trusted service origin and base path.""" + raw_url = str(value or "").strip() + raw_base_url = str(trusted_base_url or "").strip().rstrip("/") + if not raw_url or not raw_base_url: + raise OutboundHttpPolicyError("Service URLs and their trusted base URL are required.") + + parsed_url = urlsplit(raw_url) + parsed_base_url = urlsplit(raw_base_url) + try: + url_port = parsed_url.port + base_port = parsed_base_url.port + except ValueError as error: + raise OutboundHttpPolicyError("Service URLs contain an invalid port.") from error + + if ( + parsed_base_url.scheme.lower() != "https" + or parsed_url.scheme.lower() != "https" + or not parsed_base_url.hostname + or not parsed_url.hostname + or parsed_url.hostname.lower() != parsed_base_url.hostname.lower() + or (url_port or 443) != (base_port or 443) + or parsed_url.username is not None + or parsed_url.password is not None + or parsed_url.fragment + ): + raise OutboundHttpPolicyError("Service URLs must remain on the configured HTTPS origin.") + + base_path = parsed_base_url.path.rstrip("/") + candidate_path = parsed_url.path or "/" + if base_path and candidate_path != base_path and not candidate_path.startswith(f"{base_path}/"): + raise OutboundHttpPolicyError("Service URLs must remain under the configured API base path.") + + canonical_host = parsed_base_url.hostname.lower() + canonical_netloc = canonical_host if (base_port or 443) == 443 else f"{canonical_host}:{base_port}" + return urlunsplit(("https", canonical_netloc, candidate_path, parsed_url.query, "")) + + +def request_public_https( + method: Any, + url: Any, + *, + headers: Optional[dict] = None, + params: Optional[dict] = None, + auth: Any = None, + data: Any = None, + json: Any = None, + timeout: Any = 30, + stream: bool = False, + max_redirects: int = OUTBOUND_HTTP_MAX_REDIRECTS, + session: Any = None, +): + """Send an HTTP request after validating the destination and every redirect hop.""" + normalized_method = str(method or "GET").strip().upper() + if normalized_method not in OUTBOUND_HTTP_ALLOWED_METHODS: + raise OutboundHttpPolicyError("The outbound API request method is not supported.") + + current_url = normalize_public_https_url(url) + required_origin = _url_origin(current_url) + request_session = session or requests.Session() + owns_session = session is None + if owns_session: + request_session.trust_env = False + + current_params = params + current_data = data + current_json = json + try: + for redirect_count in range(max(0, int(max_redirects)) + 1): + # The destination and redirect chain are validated immediately before this sink. + # codeql[py/full-ssrf] + response = request_session.request( + normalized_method, + current_url, + headers=headers, + params=current_params, + auth=auth, + data=current_data, + json=current_json, + timeout=timeout, + stream=stream, + allow_redirects=False, + ) + if response.status_code not in {301, 302, 303, 307, 308}: + return response + if redirect_count >= max_redirects: + raise OutboundHttpPolicyError("The outbound API exceeded the redirect limit.") + + redirect_location = str(response.headers.get("Location") or "").strip() + response_url = str(response.url or current_url) + response.close() + if not redirect_location: + raise OutboundHttpPolicyError("The outbound API returned a redirect without a location.") + redirect_url = urljoin(response_url, redirect_location) + current_url = normalize_public_https_url( + redirect_url, + required_origin=required_origin, + ) + current_params = None + if response.status_code == 303 or ( + response.status_code in {301, 302} and normalized_method == "POST" + ): + normalized_method = "GET" + current_data = None + current_json = None + finally: + if owns_session: + request_session.close() + + raise OutboundHttpPolicyError("The outbound API request could not be completed.") diff --git a/application/single_app/functions_simplechat_operations.py b/application/single_app/functions_simplechat_operations.py index 567877176..ff4b97a2e 100644 --- a/application/single_app/functions_simplechat_operations.py +++ b/application/single_app/functions_simplechat_operations.py @@ -3502,14 +3502,17 @@ def _get_directory_user_by_id(user_id: str) -> Optional[Dict[str, str]]: if not token: raise PermissionError("Could not acquire access token") + # The fixed Graph origin and fully encoded directory object ID constrain this path. + # codeql[py/partial-ssrf] response = requests.get( - get_graph_endpoint(f"/users/{quote(normalized_user_id)}"), + get_graph_endpoint(f"/users/{quote(normalized_user_id, safe='')}"), headers={ "Authorization": f"Bearer {token}", "Content-Type": "application/json", }, params={"$select": "id,displayName,mail,userPrincipalName"}, timeout=20, + allow_redirects=False, ) if response.status_code == 404: return None @@ -3558,8 +3561,6 @@ def _normalize_directory_user(raw_user: Dict[str, Any]) -> Optional[Dict[str, st "displayName": display_name, "email": email, } - - def _escape_odata_value(value: str) -> str: return str(value or "").replace("'", "''").strip() diff --git a/application/single_app/route_backend_documents.py b/application/single_app/route_backend_documents.py index dcb60096e..a5a5f4d53 100644 --- a/application/single_app/route_backend_documents.py +++ b/application/single_app/route_backend_documents.py @@ -1,5 +1,7 @@ # route_backend_documents.py +from urllib.parse import quote + from config import * from functions_authentication import * from functions_documents import * @@ -2162,7 +2164,7 @@ def api_get_shared_users(document_id): approval_status = entry.get('approval_status', 'unknown') try: # Get user details from Microsoft Graph - graph_url = get_graph_endpoint(f"/users/{oid}") + graph_url = get_graph_endpoint(f"/users/{quote(str(oid), safe='')}") response = requests.get(graph_url, headers=headers) if response.status_code == 200: diff --git a/application/single_app/route_backend_models.py b/application/single_app/route_backend_models.py index 652b1558f..2f0ecaddb 100644 --- a/application/single_app/route_backend_models.py +++ b/application/single_app/route_backend_models.py @@ -10,6 +10,7 @@ from functions_settings import * from foundry_agent_runtime import FoundryAgentUserAuthenticationRequired, list_foundry_agents_from_endpoint, list_foundry_workflows_from_endpoint, list_new_foundry_agents_from_endpoint, resolve_foundry_project_base, resolve_foundry_project_api_version, build_project_credential, resolve_authority from functions_appinsights import log_event +from functions_azure_endpoint_validation import validate_azure_foundry_endpoint from model_endpoint_clients import ( MODEL_ENDPOINT_PROTOCOL_ANTHROPIC, MODEL_ENDPOINT_PROTOCOL_AZURE_OPENAI, @@ -332,6 +333,7 @@ def fetch_foundry_project_deployments(endpoint, api_version, auth_settings, proj } base = resolve_foundry_project_base(endpoint, project_name) + base = validate_azure_foundry_endpoint(base, allow_project_path=True) params = { "api-version": resolve_foundry_project_api_version(api_version), "deploymentType": "ModelDeployment" @@ -339,7 +341,14 @@ def fetch_foundry_project_deployments(endpoint, api_version, auth_settings, proj url = f"{base}/deployments" log_models_debug(f"Foundry project deployments URL={url}") - response = requests.get(url, headers=headers, params=params, timeout=30) + # codeql[py/partial-ssrf] + response = requests.get( + url, + headers=headers, + params=params, + timeout=30, + allow_redirects=False, + ) response.raise_for_status() payload = response.json() return payload.get("value", []) diff --git a/application/single_app/route_backend_public_workspaces.py b/application/single_app/route_backend_public_workspaces.py index c74e8b03b..037562a03 100644 --- a/application/single_app/route_backend_public_workspaces.py +++ b/application/single_app/route_backend_public_workspaces.py @@ -1,5 +1,7 @@ # route_backend_public_workspaces.py +from urllib.parse import quote + from config import * from functions_authentication import * from functions_chat_bootstrap_cache import bump_chat_bootstrap_global_cache_version @@ -65,7 +67,7 @@ def get_user_details_from_graph(user_id): if not token: return {"displayName": "", "email": ""} - user_endpoint = get_graph_endpoint(f"/users/{user_id}") + user_endpoint = get_graph_endpoint(f"/users/{quote(str(user_id), safe='')}") headers = { "Authorization": f"Bearer {token}", @@ -76,7 +78,14 @@ def get_user_details_from_graph(user_id): "$select": "id,displayName,mail,userPrincipalName" } - response = requests.get(user_endpoint, headers=headers, params=params) + # The fixed Graph origin and fully encoded directory object ID constrain this path. + # codeql[py/partial-ssrf] + response = requests.get( + user_endpoint, + headers=headers, + params=params, + allow_redirects=False, + ) response.raise_for_status() user_data = response.json() diff --git a/application/single_app/route_backend_settings.py b/application/single_app/route_backend_settings.py index 9f582f7ec..72f6d7e46 100644 --- a/application/single_app/route_backend_settings.py +++ b/application/single_app/route_backend_settings.py @@ -18,6 +18,7 @@ log_user_support_feedback_email_submission, ) from functions_appinsights import log_event +from functions_azure_endpoint_validation import build_azure_key_vault_endpoint from functions_cosmos_throughput import ( calculate_manual_to_autoscale_target, calculate_manual_scale_target, @@ -2052,18 +2053,15 @@ def _test_key_vault_connection(payload): return jsonify({'error': 'Key Vault name is required'}), 400 try: - vault_url = f"https://{vault_name}{KEY_VAULT_DOMAIN}" + vault_url = build_azure_key_vault_endpoint(vault_name, KEY_VAULT_DOMAIN) if client_id: credential = DefaultAzureCredential(managed_identity_client_id=client_id) else: credential = DefaultAzureCredential() - if AZURE_ENVIRONMENT == "custom": - #TODO: Needs to be tested with a custom environment - kv_client = SecretClient(vault_url=vault_url, credential=credential) - else: - kv_client = SecretClient(vault_url=vault_url, credential=credential) + # codeql[py/full-ssrf] + kv_client = SecretClient(vault_url=vault_url, credential=credential) # Perform a simple list operation to verify connectivity secrets = kv_client.list_properties_of_secrets() diff --git a/application/single_app/route_backend_users.py b/application/single_app/route_backend_users.py index 25f414d62..3aa2c9468 100644 --- a/application/single_app/route_backend_users.py +++ b/application/single_app/route_backend_users.py @@ -72,7 +72,14 @@ def _get_graph_user_info_by_id(user_id): "$select": "id,displayName,mail,userPrincipalName" } - response = requests.get(user_endpoint, headers=headers, params=params) + # The fixed Graph origin and fully encoded, authorized object ID constrain this path. + # codeql[py/partial-ssrf] + response = requests.get( + user_endpoint, + headers=headers, + params=params, + allow_redirects=False, + ) response.raise_for_status() user = response.json() or {} graph_user_id = user.get("id") or normalized_user_id diff --git a/application/single_app/semantic_kernel_plugins/openapi_plugin.py b/application/single_app/semantic_kernel_plugins/openapi_plugin.py index 30c0e8de7..fe5474526 100644 --- a/application/single_app/semantic_kernel_plugins/openapi_plugin.py +++ b/application/single_app/semantic_kernel_plugins/openapi_plugin.py @@ -58,6 +58,11 @@ from semantic_kernel.functions import kernel_function from semantic_kernel_plugins.plugin_invocation_logger import plugin_function_logger from functions_debug import debug_print +from functions_outbound_http import ( + OutboundHttpPolicyError, + normalize_public_https_url, + request_public_https, +) OPENAPI_REDACTED_VALUE = "***REDACTED***" @@ -186,7 +191,7 @@ def __init__(self, self.openapi_spec_path = openapi_spec_path self.openapi_spec_content = openapi_spec_content - self.base_url = base_url.rstrip('/') # Remove trailing slash + self.base_url = normalize_public_https_url(base_url, resolve_dns=False).rstrip('/') self.auth = auth or {} self.manifest = manifest or {} @@ -1072,26 +1077,26 @@ def _call_api_operation(self, operation_id: str, path: str, method: str, operati logging.info(f"[OPEN_API_PLUGIN] Query params: {_redact_openapi_value(query_params)}") if method.lower() == 'get': - response = requests.get(full_url, headers=headers, params=query_params, timeout=30) + response = request_public_https('GET', full_url, headers=headers, params=query_params, timeout=30) # Log the actual URL that was requested debug_print(f"Actual GET request URL: {_redact_openapi_url(response.url)}") debug_print(f"Response status: {response.status_code}") logging.info(f"[OPEN_API_PLUGIN] Actual GET request URL: {_redact_openapi_url(response.url)}") elif method.lower() == 'post': - response = requests.post(full_url, headers=headers, params=query_params, json=kwargs, timeout=30) + response = request_public_https('POST', full_url, headers=headers, params=query_params, json=kwargs, timeout=30) logging.info(f"[OPEN_API_PLUGIN] Actual POST request URL: {_redact_openapi_url(response.url)}") elif method.lower() == 'put': - response = requests.put(full_url, headers=headers, params=query_params, json=kwargs, timeout=30) + response = request_public_https('PUT', full_url, headers=headers, params=query_params, json=kwargs, timeout=30) logging.info(f"[OPEN_API_PLUGIN] Actual PUT request URL: {_redact_openapi_url(response.url)}") elif method.lower() == 'delete': - response = requests.delete(full_url, headers=headers, params=query_params, timeout=30) + response = request_public_https('DELETE', full_url, headers=headers, params=query_params, timeout=30) logging.info(f"[OPEN_API_PLUGIN] Actual DELETE request URL: {_redact_openapi_url(response.url)}") elif method.lower() == 'patch': - response = requests.patch(full_url, headers=headers, params=query_params, json=kwargs, timeout=30) + response = request_public_https('PATCH', full_url, headers=headers, params=query_params, json=kwargs, timeout=30) logging.info(f"[OPEN_API_PLUGIN] Actual PATCH request URL: {_redact_openapi_url(response.url)}") else: # Default to GET for unknown methods - response = requests.get(full_url, headers=headers, params=query_params, timeout=30) + response = request_public_https('GET', full_url, headers=headers, params=query_params, timeout=30) logging.info(f"[OPEN_API_PLUGIN] Actual GET request URL: {_redact_openapi_url(response.url)}") debug_print(f"Response status: {response.status_code}") @@ -1189,7 +1194,7 @@ def _call_api_operation(self, operation_id: str, path: str, method: str, operati return error_result - except requests.exceptions.RequestException as req_error: + except (requests.exceptions.RequestException, OutboundHttpPolicyError) as req_error: redacted_request_error = _redact_openapi_string(str(req_error)) debug_print(f"Request exception: {redacted_request_error}") logging.error(f"[OPEN_API_PLUGIN] Request error for {operation_id}: {redacted_request_error}") diff --git a/application/single_app/semantic_kernel_plugins/plugin_health_checker.py b/application/single_app/semantic_kernel_plugins/plugin_health_checker.py index 4d7088676..c797b600f 100644 --- a/application/single_app/semantic_kernel_plugins/plugin_health_checker.py +++ b/application/single_app/semantic_kernel_plugins/plugin_health_checker.py @@ -15,6 +15,7 @@ validate_azure_cosmos_endpoint, validate_azure_databricks_endpoint, validate_azure_entra_authority_host, + validate_azure_maps_endpoint, validate_azure_monitor_query_endpoint, validate_azure_queue_endpoint, ) @@ -528,6 +529,13 @@ def validate_plugin_manifest(manifest: Dict[str, Any], plugin_type: str) -> Tupl auth = manifest.get('auth', {}) if isinstance(manifest.get('auth'), dict) else {} if not endpoint: errors.append(f"Azure Maps plugin requires an 'endpoint' field (use {AZURE_MAPS_DEFAULT_ENDPOINT})") + else: + errors.extend( + PluginHealthChecker._endpoint_origin_errors( + endpoint, + validate_azure_maps_endpoint, + ) + ) if auth.get('type') != 'key': errors.append("Azure Maps plugin requires auth.type='key'") if not auth.get('key'): diff --git a/docs/explanation/features/CONTENT_UNDERSTANDING_ENHANCED_EXTRACTION.md b/docs/explanation/features/CONTENT_UNDERSTANDING_ENHANCED_EXTRACTION.md index 6b18429d3..c2070cd11 100644 --- a/docs/explanation/features/CONTENT_UNDERSTANDING_ENHANCED_EXTRACTION.md +++ b/docs/explanation/features/CONTENT_UNDERSTANDING_ENHANCED_EXTRACTION.md @@ -76,6 +76,11 @@ and `extraction_engine_reason`, and are surfaced in workspace tooltips. | Poll | `GET` the `Operation-Location` response header until `status` is `Succeeded`, `Failed`, or `Canceled`. | | Auth | `Ocp-Apim-Subscription-Key: ` or `Authorization: Bearer ` for the `https://cognitiveservices.azure.com/.default` scope. | +The configured endpoint must be the canonical root of an Azure AI Foundry resource under a +supported `services.ai.azure.*` hostname. Paths, URL credentials, custom ports, query strings, and +lookalike suffixes are rejected. Polling follows only `Operation-Location` URLs on that same Foundry +origin and under the `/contentunderstanding/` namespace. + Per-page content is reconstructed by slicing the content-level `markdown` string with each page's `spans` (`{offset, length}`). Figure descriptions from `figures[]` are attributed to the page whose span range contains the figure offset, and are skipped when the description is already inlined in 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..97cc5163f 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 @@ -16,6 +16,10 @@ Implemented in version: **0.241.127** The connector stores `source_type: "azure_files"` and a connection payload with `account_url`, `share_name`, `directory_path`, and `share_url`. Sync runs list files through the Azure Files SDK, stage downloads into the same temporary-file pipeline as SMB sync, and persist synced document metadata with the Azure Files source type. +The file service URL is canonicalized before save and validated again before SDK client creation. +It must use an Azure Files hostname such as `https://account.file.core.windows.net`; arbitrary hosts, +IP literals, URL credentials, nonstandard ports, query strings, and fragments are rejected. + Supported reusable workspace identity authentication methods for Azure Files are: - Managed identity diff --git a/docs/explanation/fixes/CRITICAL_SSRF_HARDENING_FIX.md b/docs/explanation/fixes/CRITICAL_SSRF_HARDENING_FIX.md new file mode 100644 index 000000000..859024c4f --- /dev/null +++ b/docs/explanation/fixes/CRITICAL_SSRF_HARDENING_FIX.md @@ -0,0 +1,76 @@ +# Critical SSRF Hardening Fix - Version 0.260.029 + +Fixed in version: **0.260.029** + +Related pull request: [#1335](https://github.com/microsoft/simplechat/pull/1335) + +## Issue Description + +The Development-to-Staging promotion check reported 15 critical CodeQL server-side request forgery +results. Several credentialed clients accepted an endpoint, redirect, or pagination URL derived from +settings or an authenticated request without enforcing the destination again at the network boundary. +An authenticated user or administrator with configuration access could direct some connection tests, +action calls, or discovery requests toward an unintended server. + +## Root Cause Analysis + +Endpoint handling was implemented independently by each integration. Some paths trimmed strings or +checked only for HTTPS, while others trusted redirects or server-returned pagination URLs. Save-time +validation did not protect older stored records, and automatic redirects could move a credentialed +request away from its original host. CodeQL also reported two fixed-origin URL builders because it did +not infer their path constraints. + +## Technical Details + +### Files Modified + +- `application/single_app/functions_azure_endpoint_validation.py` +- `application/single_app/functions_outbound_http.py` +- Content Understanding, File Sync, action connection test, model endpoint, Key Vault, Cosmos + throughput, public workspace, user, and document route helpers +- Focused functional tests under `functional_tests/` + +### Code Changes + +- Added canonical Azure service validators for Blob Storage, Azure Files, Foundry, Content + Understanding, Azure Maps, and Key Vault. +- Revalidated destinations immediately before SDK client construction or credentialed HTTP calls so + legacy stored settings cannot bypass current save validation. +- Added a public HTTPS policy for OpenAPI actions that rejects local, private, metadata, reserved, and + mixed public/private DNS results; disables environment proxies; and revalidates every redirect. +- Required OpenAPI redirects to remain on the original origin before forwarding configured headers or + authentication. +- Restricted OneDrive pagination to the configured Microsoft Graph origin. Graph download redirects + are validated as public HTTPS and receive no Graph authorization header. +- Fully encoded directory object IDs before adding them to Microsoft Graph paths. +- Required Cosmos management paths to remain relative `/subscriptions/...` resource IDs under the + deployment-controlled Azure Resource Manager origin. +- Centralized Key Vault URL construction around a validated vault name and trusted cloud suffix. + +## Testing Approach + +`functional_tests/test_outbound_http_ssrf_policy.py` covers hostile schemes, URL credentials, IP +literals, private and metadata addresses, mixed DNS answers, traversal segments, same-origin +redirects, and blocked cross-origin credential forwarding. Existing endpoint, Content Understanding, +File Sync, Enhanced Citations, Cosmos throughput, action connection, model endpoint, and route tests +cover integration behavior. + +## Impact Analysis + +Canonical Azure service endpoints continue to work across supported public, US Government, China, +and Germany suffixes where the integration supports those clouds. OpenAPI actions now require public +HTTPS port 443 and same-origin redirects; private-network APIs and custom ports are intentionally +rejected. Content Understanding and Foundry discovery require canonical Azure AI Foundry hosts. + +The Cosmos throughput and authorized user-profile Graph results are controlled paths rather than +exploitable SSRF: both use deployment-owned service origins, percent-encoded identifiers, and explicit +relative-path or object-authorization checks. Narrow CodeQL suppressions document those reviewed +boundaries. + +## Validation + +- Before: configurable or server-returned destinations could reach credentialed clients without a + shared last-boundary policy. +- After: every reported destination is canonicalized, origin-constrained, public-network validated, + or proven to be a fixed-origin encoded path before the network call. +- The application version was updated from `0.260.028` to `0.260.029`. diff --git a/docs/explanation/release_notes.md b/docs/explanation/release_notes.md index 5c27acd58..7a8447a04 100644 --- a/docs/explanation/release_notes.md +++ b/docs/explanation/release_notes.md @@ -2,6 +2,15 @@ For feature-focused and fix-focused drill-downs by version, see [Features by Version](https://github.com/microsoft/simplechat/tree/main/docs/explanation/features) and [Fixes by Version](https://github.com/microsoft/simplechat/tree/main/docs/explanation/fixes). +### **(v0.260.029)** + +#### Bug Fixes + +* **Credentialed Outbound Requests Stay On Approved Destinations** + * Hardened OpenAPI actions, Content Understanding, Foundry model discovery, Key Vault, Enhanced Citations storage, Azure Files, Azure Maps, OneDrive, Microsoft Graph, and Cosmos throughput management against server-side request forgery. + * OpenAPI calls now require public HTTPS on port 443, reject local and private DNS results, ignore environment proxies, and revalidate every redirect without forwarding credentials across origins. Azure and Microsoft service clients independently validate canonical service endpoints at the final network boundary, including older stored configurations. + * (Ref: `functions_outbound_http.py`, `functions_azure_endpoint_validation.py`, CodeQL critical SSRF findings, [#1335](https://github.com/microsoft/simplechat/pull/1335)) + ### **(v0.260.025)** #### Bug Fixes diff --git a/docs/guides/model-endpoint-identity-setup.md b/docs/guides/model-endpoint-identity-setup.md index 811c306f8..1cef0bf4e 100644 --- a/docs/guides/model-endpoint-identity-setup.md +++ b/docs/guides/model-endpoint-identity-setup.md @@ -94,6 +94,11 @@ The modal provider decides which discovery API and token scope Simple Chat uses. | `Foundry (classic)` | Existing classic Foundry project agents and model deployments | `https://.services.ai.azure.com/api/projects/` or the project base endpoint plus **Foundry Project Name** | The Foundry project when the portal exposes project-scoped access, otherwise the backing Foundry resource/account | | `New Foundry` | Application-based Foundry runtime, New Foundry agents, and OpenAI-compatible project model deployments | The same Foundry project endpoint shape used by the New Foundry project | The Foundry project when the portal exposes project-scoped access, otherwise the backing Foundry resource/account | +Foundry discovery accepts only canonical HTTPS `services.ai.azure.*` resource hosts and an optional +`/api/projects/` path. SimpleChat validates this destination immediately before attaching a +managed-identity or service-principal token, including requests made from personal and group endpoint +configuration screens. + For APIM, choose the provider that matches the backend service and select API key authentication when APIM expects a subscription key or other shared key. API key authentication can run inference, but it cannot use **Fetch Models** for Azure OpenAI ARM discovery or Foundry project discovery. ## Choose API Versions diff --git a/docs/reference/actions/openapi.md b/docs/reference/actions/openapi.md index 4f5090ecb..8a5ebb5ec 100644 --- a/docs/reference/actions/openapi.md +++ b/docs/reference/actions/openapi.md @@ -25,6 +25,12 @@ Use OpenAPI when an HTTP API has a maintained OpenAPI spec and users need agents - Authentication details: no auth, API key, bearer token, basic auth, OAuth2 access token, or compatible reusable identity. - Agents/actions enabled with [`enable_semantic_kernel`]({{ '/admin/agents-actions/' | relative_url }}). +The base URL must use HTTPS on port 443 with a public DNS hostname. SimpleChat rejects IP literals, +embedded URL credentials, local or private network destinations, and hostnames that resolve to any +non-public address. Redirects are revalidated and must remain on the original origin so configured +credentials are never forwarded to another host. APIs reachable only through private DNS or custom +ports are not supported by this action. + ## Configure the action 1. Choose **OpenAPI**. @@ -49,6 +55,7 @@ Use OpenAPI when an HTTP API has a maintained OpenAPI spec and users need agents | Operation names are hard for the agent to choose | The spec has missing or ambiguous operation IDs. | Improve operation IDs and summaries, then re-upload. | | Authentication fails | Wrong auth type, header name, token, or query parameter is configured. | Match modal auth fields to the API security scheme. | | Spec upload fails | File is invalid JSON/YAML or too large. | Validate the spec and keep it under the documented upload limit. | +| Connection is rejected before the API responds | The base URL is not public HTTPS, resolves to a non-public address, or redirects to another origin. | Use the API's canonical public HTTPS origin and keep redirects on that origin. | ## Related diff --git a/functional_tests/test_action_app_identity_endpoint_hardening.py b/functional_tests/test_action_app_identity_endpoint_hardening.py index 00336390d..4aca7aa03 100644 --- a/functional_tests/test_action_app_identity_endpoint_hardening.py +++ b/functional_tests/test_action_app_identity_endpoint_hardening.py @@ -2,8 +2,9 @@ # test_action_app_identity_endpoint_hardening.py """ Functional test for action app-identity endpoint hardening. -Version: 0.260.006 +Version: 0.260.029 Implemented in: 0.260.006 +Updated in: 0.260.029 Actions can be configured with a caller-supplied endpoint while authenticating with the application's own workload identity. This test ensures such endpoints are constrained to @@ -82,7 +83,7 @@ def test_version_and_definition_contract(): """Validate the version bump and the blob storage auth type declaration.""" import json - assert_app_version_at_least("0.260.006") + assert_app_version_at_least("0.260.029") definition = json.loads(read_text("application/single_app/static/json/schemas/blob_storage.definition.json")) allowed = definition["allowedAuthTypes"] @@ -90,9 +91,10 @@ def test_version_and_definition_contract(): def test_blob_and_queue_endpoint_allowlist(): - """Blob and queue endpoints must be canonical Azure Storage origins.""" + """Blob, queue, and file endpoints must be canonical Azure Storage origins.""" from functions_azure_endpoint_validation import ( validate_azure_blob_endpoint, + validate_azure_file_endpoint, validate_azure_queue_endpoint, ) @@ -100,6 +102,7 @@ def test_blob_and_queue_endpoint_allowlist(): assert validate_azure_blob_endpoint("https://acct.blob.core.usgovcloudapi.net/") == "https://acct.blob.core.usgovcloudapi.net" assert validate_azure_blob_endpoint("https://acct.blob.core.chinacloudapi.cn") == "https://acct.blob.core.chinacloudapi.cn" assert validate_azure_queue_endpoint("https://acct.queue.core.windows.net") == "https://acct.queue.core.windows.net" + assert validate_azure_file_endpoint("https://acct.file.core.windows.net") == "https://acct.file.core.windows.net" for hostile_endpoint in HOSTILE_ENDPOINTS: try: @@ -112,6 +115,7 @@ def test_blob_and_queue_endpoint_allowlist(): for mismatched_endpoint, validator in ( ("https://acct.queue.core.windows.net", validate_azure_blob_endpoint), ("https://acct.blob.core.windows.net", validate_azure_queue_endpoint), + ("https://acct.blob.core.windows.net", validate_azure_file_endpoint), ): try: validator(mismatched_endpoint) @@ -157,6 +161,47 @@ def test_cosmos_databricks_and_monitor_endpoint_allowlist(): raise AssertionError(f"{validator.__name__} should have rejected {hostile_value}") +def test_foundry_maps_and_key_vault_endpoint_allowlists(): + """Credentialed Azure clients must only receive canonical service destinations.""" + from functions_azure_endpoint_validation import ( + build_azure_key_vault_endpoint, + validate_azure_content_understanding_endpoint, + validate_azure_foundry_endpoint, + validate_azure_maps_endpoint, + ) + + assert validate_azure_content_understanding_endpoint( + "https://resource.services.ai.azure.com/" + ) == "https://resource.services.ai.azure.com" + assert validate_azure_foundry_endpoint( + "https://resource.services.ai.azure.us/api/projects/project-one/", + allow_project_path=True, + ) == "https://resource.services.ai.azure.us/api/projects/project-one" + assert validate_azure_maps_endpoint("https://atlas.microsoft.com/") == "https://atlas.microsoft.com" + assert build_azure_key_vault_endpoint("vault-one", ".vault.azure.net") == "https://vault-one.vault.azure.net" + + rejected_values = ( + (validate_azure_content_understanding_endpoint, "https://evil.example.com"), + (validate_azure_content_understanding_endpoint, "https://resource.services.ai.azure.com/api/projects/p"), + (validate_azure_content_understanding_endpoint, "https://resource.services.ai.azure.com.evil.example"), + (validate_azure_maps_endpoint, "https://169.254.169.254"), + (validate_azure_maps_endpoint, "https://atlas.microsoft.com.evil.example"), + ) + for validator, hostile_value in rejected_values: + try: + validator(hostile_value) + except ValueError: + continue + raise AssertionError(f"{validator.__name__} should have rejected {hostile_value}") + + for hostile_name in ("evil.example", "vault/path", "vault@evil", "-vault", "vault-"): + try: + build_azure_key_vault_endpoint(hostile_name, ".vault.azure.net") + except ValueError: + continue + raise AssertionError(f"Key Vault name should have been rejected: {hostile_name}") + + def test_allowed_auth_types_are_enforced_server_side(): """A caller must not be able to declare an auth type its action type does not support.""" from json_schema_validation import ( @@ -237,6 +282,14 @@ def is_valid(manifest, plugin_type): "databricks", ) + maps_manifest = { + "name": "maps", + "type": "azure_maps_openlayers", + "auth": {"type": "key", "key": "fake-key"}, + } + assert not is_valid({**maps_manifest, "endpoint": "https://attacker.invalid"}, "azure_maps_openlayers") + assert is_valid({**maps_manifest, "endpoint": "https://atlas.microsoft.com"}, "azure_maps_openlayers") + def test_log_analytics_custom_cloud_is_constrained(): """A custom Log Analytics cloud must not select the token authority or OAuth resource.""" @@ -320,6 +373,12 @@ def test_runtime_validation_is_wired_into_credentialed_clients(): databricks_source = read_text("application/single_app/semantic_kernel_plugins/databricks_plugin.py") log_analytics_source = read_text("application/single_app/semantic_kernel_plugins/log_analytics_plugin.py") plugin_routes_source = read_text("application/single_app/route_backend_plugins.py") + config_source = read_text("application/single_app/config.py") + file_sync_source = read_text("application/single_app/functions_file_sync.py") + action_tests_source = read_text("application/single_app/functions_action_connection_tests.py") + model_routes_source = read_text("application/single_app/route_backend_models.py") + settings_routes_source = read_text("application/single_app/route_backend_settings.py") + key_vault_source = read_text("application/single_app/functions_keyvault.py") assert "validate_azure_blob_endpoint" in blob_source assert "account_url=self.endpoint" not in blob_source @@ -333,6 +392,13 @@ def test_runtime_validation_is_wired_into_credentialed_clients(): assert "authority_host = self.authority_host" not in log_analytics_source # The Cosmos test-connection route builds its own client and must validate independently. assert "validate_azure_cosmos_endpoint" in plugin_routes_source + assert "safe_blob_endpoint = validate_azure_blob_endpoint(blob_endpoint)" in config_source + assert "safe_account_url = validate_azure_file_endpoint(account_url)" in file_sync_source + assert "endpoint = validate_azure_maps_endpoint(raw_endpoint)" in action_tests_source + assert "base = validate_azure_foundry_endpoint(base, allow_project_path=True)" in model_routes_source + assert "build_azure_key_vault_endpoint(vault_name, KEY_VAULT_DOMAIN)" in settings_routes_source + assert "def _build_key_vault_endpoint(vault_name):" in key_vault_source + assert 'f"https://{key_vault_name}{KEY_VAULT_DOMAIN}"' not in key_vault_source def test_blob_storage_modal_exposes_identity_and_key(): @@ -374,6 +440,7 @@ def test_file_sync_reuses_the_shared_allowlist(): test_version_and_definition_contract, test_blob_and_queue_endpoint_allowlist, test_cosmos_databricks_and_monitor_endpoint_allowlist, + test_foundry_maps_and_key_vault_endpoint_allowlists, test_allowed_auth_types_are_enforced_server_side, test_manifest_validation_rejects_hostile_endpoints, test_log_analytics_custom_cloud_is_constrained, diff --git a/functional_tests/test_action_connection_test_secret_redaction.py b/functional_tests/test_action_connection_test_secret_redaction.py index b0161346c..f366f295e 100644 --- a/functional_tests/test_action_connection_test_secret_redaction.py +++ b/functional_tests/test_action_connection_test_secret_redaction.py @@ -2,8 +2,9 @@ # test_action_connection_test_secret_redaction.py """ Functional test for action connection test error sanitization. -Version: 0.250.217 +Version: 0.260.029 Implemented in: 0.250.217 +Updated in: 0.260.029 This test ensures that action Test Connection failures never echo stored credentials back to the browser. It covers manifest-sourced secrets, generic @@ -22,6 +23,8 @@ REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +APP_ROOT = os.path.join(REPO_ROOT, "application", "single_app") +sys.path.insert(0, APP_ROOT) sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from test_support.versioning import assert_app_version_at_least # noqa: E402 @@ -76,8 +79,8 @@ def test_manifest_secrets_are_redacted(): try: assert_app_version_at_least( - "0.250.217", - reason="Action connection test sanitization was added in 0.250.217.", + "0.260.029", + reason="Action connection test destination hardening was added in 0.260.029.", ) module = _load_tester_module() diff --git a/functional_tests/test_content_understanding_extraction_engine.py b/functional_tests/test_content_understanding_extraction_engine.py index 3b6b5c706..edce0e168 100644 --- a/functional_tests/test_content_understanding_extraction_engine.py +++ b/functional_tests/test_content_understanding_extraction_engine.py @@ -2,8 +2,9 @@ # test_content_understanding_extraction_engine.py """ Functional test for Enhanced extraction backed by Azure AI Content Understanding. -Version: 0.250.224 +Version: 0.260.029 Implemented in: 0.250.221 +Updated in: 0.260.029 This test ensures that the Content Understanding client parses analyzer results into the same page shape Document Intelligence returns, that Enhanced extraction resolves to the right engine @@ -304,6 +305,57 @@ def test_government_cloud_blocks_content_understanding(): return True +def test_content_understanding_destinations_are_constrained(): + """Configured and server-returned URLs must stay on the selected Foundry resource.""" + print("Testing Content Understanding destination validation...") + + content_understanding, _ = load_content_understanding_module() + config = { + "endpoint": "https://example.services.ai.azure.com", + "key": "fake-key", + "authentication_type": "key", + "api_version": "2025-11-01", + "analyzer_id": "prebuilt-documentSearch", + } + content_understanding._validate_config(config) + + safe_location = content_understanding._canonicalize_operation_location( + "https://example.services.ai.azure.com/contentunderstanding/operations/job-1?api-version=2025-11-01", + config, + ) + if not safe_location.startswith( + "https://example.services.ai.azure.com/contentunderstanding/operations/job-1" + ): + raise AssertionError(f"Unexpected canonical operation URL: {safe_location}") + + hostile_values = ( + "https://169.254.169.254", + "https://example.services.ai.azure.com.evil.example", + ) + for hostile_endpoint in hostile_values: + hostile_config = dict(config, endpoint=hostile_endpoint) + try: + content_understanding._validate_config(hostile_config) + except content_understanding.ContentUnderstandingNotConfiguredError: + continue + raise AssertionError(f"Hostile endpoint should have been rejected: {hostile_endpoint}") + + for hostile_location in ( + "https://evil.example/contentunderstanding/operations/job-1", + "https://example.services.ai.azure.com/metadata/identity/oauth2/token", + "https://example.services.ai.azure.com/contentunderstanding/%2e%2e/metadata", + "http://example.services.ai.azure.com/contentunderstanding/operations/job-1", + ): + try: + content_understanding._canonicalize_operation_location(hostile_location, config) + except content_understanding.ContentUnderstandingError: + continue + raise AssertionError(f"Hostile operation URL should have been rejected: {hostile_location}") + + print("Content Understanding destination validation passed!") + return True + + def read_repo_file(relative_path): """Read a repository file as UTF-8 text.""" _path = REPO_ROOT / relative_path @@ -792,7 +844,7 @@ def test_formula_extraction_is_opt_in_and_layout_only(): def test_version_is_at_least_implementation_version(): """The app version must be at or beyond the version this feature shipped in.""" print("Testing application version...") - assert_app_version_at_least("0.250.221") + assert_app_version_at_least("0.260.029") print("Version test passed!") return True diff --git a/functional_tests/test_cosmos_throughput_autoscale_logic.py b/functional_tests/test_cosmos_throughput_autoscale_logic.py index c9125e5fd..399eb6a07 100644 --- a/functional_tests/test_cosmos_throughput_autoscale_logic.py +++ b/functional_tests/test_cosmos_throughput_autoscale_logic.py @@ -2,12 +2,13 @@ # test_cosmos_throughput_autoscale_logic.py """ Functional test for Cosmos throughput autoscale decision logic. -Version: 0.241.199 +Version: 0.260.029 Implemented in: 0.241.147; container policy enforcement added in 0.241.153; container metric guardrail added in 0.241.155; manual-to-autoscale conversion added in 0.241.159; migrateToAutoscale ARM action fix added in 0.241.160; save validation added in 0.241.161; access validation added in 0.241.162 Enhanced in: 0.241.183 with detailed access validation diagnostics for partial Azure permission failures. Enhanced in: 0.241.184 with neutral container-targeted throughput status language. Enhanced in: 0.241.194 with dedicated container scale-up-to-max coverage when mixed database and container throughput exist. Enhanced in: 0.241.199 with SimpleChat's 10,000 RU/s scaling support ceiling and portal-managed monitoring coverage. +Updated in: 0.260.029 with fixed-origin ARM resource path validation. This test ensures that Cosmos DB throughput automation scales the shared SimpleChat database up and down using separate thresholds, cooldowns, and @@ -869,6 +870,31 @@ def fake_build_resource_ids(settings=None): assert status['containers'][0]['is_scalable'] is True +def test_arm_resource_paths_cannot_change_the_management_origin(): + """ARM request paths must remain relative, encoded Cosmos resource IDs.""" + valid_path = ( + "/subscriptions/subscription-id/resourceGroups/group-name/providers/" + "Microsoft.DocumentDB/databaseAccounts/account/sqlDatabases/db/throughputSettings/default" + ) + assert cosmos_throughput._validate_arm_resource_path(valid_path) == valid_path + + invalid_paths = ( + "https://attacker.example/subscriptions/subscription-id", + "//attacker.example/subscriptions/subscription-id", + "/subscriptions/subscription-id?api-version=attacker", + "/subscriptions/subscription-id#fragment", + "/subscriptions/subscription-id\\evil", + "/subscriptions/subscription-id\nevil", + "/providers/Microsoft.DocumentDB/databaseAccounts/account", + ) + for invalid_path in invalid_paths: + try: + cosmos_throughput._validate_arm_resource_path(invalid_path) + except CosmosThroughputError: + continue + raise AssertionError(f"Unsafe ARM resource path should have been rejected: {invalid_path!r}") + + if __name__ == "__main__": tests = [ test_scales_up_when_utilization_is_high, @@ -899,6 +925,7 @@ def fake_build_resource_ids(settings=None): test_access_validation_uses_neutral_container_targeted_language, test_access_validation_reports_partial_azure_failures, test_status_returns_partial_failure_details_for_validate_access, + test_arm_resource_paths_cannot_change_the_management_origin, ] results = [] for test in tests: diff --git a/functional_tests/test_enhanced_citations_startup_storage_degradation.py b/functional_tests/test_enhanced_citations_startup_storage_degradation.py index 29d6dff54..1da2e79a4 100644 --- a/functional_tests/test_enhanced_citations_startup_storage_degradation.py +++ b/functional_tests/test_enhanced_citations_startup_storage_degradation.py @@ -2,8 +2,9 @@ #!/usr/bin/env python3 """ Functional test for Enhanced Citations startup storage degradation. -Version: 0.250.126 +Version: 0.260.029 Implemented in: 0.250.126 +Updated in: 0.260.029 This test ensures Enhanced Citations storage stays an optional dependency during startup and that storage container readiness is handled by feature/admin paths. @@ -51,9 +52,9 @@ def test_startup_storage_initialization_is_non_blocking(): startup_source = get_function_source(CONFIG_PATH, "_initialize_enhanced_citations_storage_client") assert_app_version_at_least( - "0.250.126", + "0.260.029", repo_root=REPO_ROOT, - reason="Enhanced Citations startup storage degradation fix requires this version or newer.", + reason="Enhanced Citations storage endpoint hardening requires this version or newer.", ) if ".exists(" in startup_source: raise AssertionError("Startup initialization must not call container exists().") @@ -66,6 +67,10 @@ def test_startup_storage_initialization_is_non_blocking(): if "except Exception as exc" not in startup_source: raise AssertionError("Optional storage client setup must not be able to fail app startup.") + builder_source = get_function_source(CONFIG_PATH, "build_enhanced_citations_blob_service_client") + if "validate_azure_blob_endpoint(blob_endpoint)" not in builder_source: + raise AssertionError("Managed identity storage must reject non-Azure Blob endpoints.") + print("Startup initialization is non-blocking for Enhanced Citations storage.") diff --git a/functional_tests/test_file_sync_onedrive_personal.py b/functional_tests/test_file_sync_onedrive_personal.py index 828ceeccc..832142a7a 100644 --- a/functional_tests/test_file_sync_onedrive_personal.py +++ b/functional_tests/test_file_sync_onedrive_personal.py @@ -2,13 +2,14 @@ # test_file_sync_onedrive_personal.py """ Functional test for personal OneDrive File Sync support. -Version: 0.250.072 +Version: 0.260.029 Implemented in: 0.241.128 Updated in: 0.250.067 Updated in: 0.250.068 Updated in: 0.250.069 Updated in: 0.250.070 Updated in: 0.250.072 +Updated in: 0.260.029 This test ensures OneDrive sync source code remains wired as personal-only File Sync support while the admin source-type control keeps OneDrive marked as coming @@ -49,7 +50,7 @@ def test_version_and_source_defaults(): settings_text = read_text("application/single_app/functions_settings.py") file_sync_text = read_text("application/single_app/functions_file_sync.py") - assert_app_version_at_least("0.250.072") + assert_app_version_at_least("0.260.029") assert "FILE_SYNC_SOURCE_TYPE_ONEDRIVE = \"onedrive\"" in file_sync_text assert "FILE_SYNC_SOURCE_TYPE_ONEDRIVE" in file_sync_text assert "FILE_SYNC_SOURCE_TYPE_ONEDRIVE: {\"client_secret\"}" in file_sync_text @@ -85,7 +86,13 @@ def test_onedrive_backend_provider_wiring(): assert "remote_change_token" in file_sync_text assert "selected_paths" in file_sync_text assert "onedrive://" in file_sync_text - assert "requests.get(download_url" in file_sync_text + assert "response = requests.get(" in file_sync_text + assert "download_url," in file_sync_text + assert "normalize_same_origin_https_url(candidate_url, get_graph_base_url())" in file_sync_text + assert "allow_redirects=False" in file_sync_text + assert 'headers={"Authorization": f"Bearer {_get_graph_app_token()}"}' in file_sync_text + assert "safe_download_url = normalize_public_https_url(redirect_location)" in file_sync_text + assert 'request_public_https(\n "GET",\n safe_download_url' in file_sync_text def test_global_connector_identity_supports_cloud_drive_sync(): diff --git a/functional_tests/test_outbound_http_ssrf_policy.py b/functional_tests/test_outbound_http_ssrf_policy.py new file mode 100644 index 000000000..879c7cf59 --- /dev/null +++ b/functional_tests/test_outbound_http_ssrf_policy.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python3 +# test_outbound_http_ssrf_policy.py +""" +Functional test for outbound HTTP SSRF prevention. +Version: 0.260.029 +Implemented in: 0.260.029 + +This test ensures user-configured API requests only reach public HTTPS destinations, +revalidate DNS before each request, and never forward credentials across origins. +""" + +import socket +import sys +from pathlib import Path +from unittest.mock import patch + + +REPO_ROOT = Path(__file__).resolve().parents[1] +APP_ROOT = REPO_ROOT / "application" / "single_app" +sys.path.insert(0, str(APP_ROOT)) + +from test_support.versioning import assert_app_version_at_least # noqa: E402 +from functions_outbound_http import ( # noqa: E402 + OutboundHttpPolicyError, + normalize_public_https_url, + normalize_same_origin_https_url, + request_public_https, +) + + +def _address_info(*addresses): + return [ + (socket.AF_INET6 if ":" in address else socket.AF_INET, socket.SOCK_STREAM, 6, "", (address, 443)) + for address in addresses + ] + + +class _FakeResponse: + def __init__(self, status_code=200, url="https://api.example.com/", location=""): + self.status_code = status_code + self.url = url + self.headers = {"Location": location} if location else {} + self.closed = False + + def close(self): + self.closed = True + + +class _FakeSession: + def __init__(self, responses): + self.responses = list(responses) + self.requests = [] + + def request(self, method, url, **kwargs): + self.requests.append((method, url, kwargs)) + return self.responses.pop(0) + + +def test_version_contract(): + assert_app_version_at_least("0.260.029") + + +def test_structural_url_policy(): + assert normalize_public_https_url( + "https://api.example.com/v1?limit=2", + resolve_dns=False, + ) == "https://api.example.com/v1?limit=2" + + rejected_urls = ( + "http://api.example.com", + "https://user:password@api.example.com", + "https://api.example.com:8443", + "https://127.0.0.1", + "https://localhost", + "https://metadata.google.internal", + "https://api.example.com/%2e%2e/metadata", + "https://api.example.com/%252e%252e/metadata", + "https://api.example.com/\\metadata", + "https://api.example.com/#fragment", + ) + for rejected_url in rejected_urls: + try: + normalize_public_https_url(rejected_url, resolve_dns=False) + except OutboundHttpPolicyError: + continue + raise AssertionError(f"Outbound URL should have been rejected: {rejected_url}") + + +def test_dns_policy_rejects_any_non_public_answer(): + with patch("functions_outbound_http.socket.getaddrinfo", return_value=_address_info("93.184.216.34")): + normalize_public_https_url("https://api.example.com") + + for addresses in ( + ("127.0.0.1",), + ("169.254.169.254",), + ("10.1.2.3",), + ("93.184.216.34", "10.1.2.3"), + ("::1",), + ): + with patch("functions_outbound_http.socket.getaddrinfo", return_value=_address_info(*addresses)): + try: + normalize_public_https_url("https://api.example.com") + except OutboundHttpPolicyError: + continue + raise AssertionError(f"DNS answers should have been rejected: {addresses}") + + +def test_same_origin_service_policy(): + trusted_graph_base = "https://graph.microsoft.com/v1.0" + assert normalize_same_origin_https_url( + "https://graph.microsoft.com/v1.0/users?$top=5", + trusted_graph_base, + ) == "https://graph.microsoft.com/v1.0/users?$top=5" + + for rejected_url in ( + "https://attacker.example/v1.0/users", + "https://graph.microsoft.com/beta/users", + "http://graph.microsoft.com/v1.0/users", + "https://user:password@graph.microsoft.com/v1.0/users", + ): + try: + normalize_same_origin_https_url(rejected_url, trusted_graph_base) + except OutboundHttpPolicyError: + continue + raise AssertionError(f"Cross-origin service URL should have been rejected: {rejected_url}") + + +def test_request_policy_allows_same_origin_redirects(): + fake_session = _FakeSession([ + _FakeResponse(302, "https://api.example.com/start", "/v2"), + _FakeResponse(200, "https://api.example.com/v2"), + ]) + with patch("functions_outbound_http.socket.getaddrinfo", return_value=_address_info("93.184.216.34")): + response = request_public_https( + "GET", + "https://api.example.com/start", + headers={"Authorization": "Bearer secret"}, + session=fake_session, + ) + + assert response.status_code == 200 + assert len(fake_session.requests) == 2 + assert fake_session.requests[1][1] == "https://api.example.com/v2" + assert fake_session.requests[1][2]["headers"]["Authorization"] == "Bearer secret" + assert all(request[2]["allow_redirects"] is False for request in fake_session.requests) + + +def test_request_policy_blocks_cross_origin_redirects_before_credentials_are_sent(): + fake_session = _FakeSession([ + _FakeResponse(302, "https://api.example.com/start", "https://attacker.example/steal"), + ]) + with patch("functions_outbound_http.socket.getaddrinfo", return_value=_address_info("93.184.216.34")): + try: + request_public_https( + "GET", + "https://api.example.com/start", + headers={"Authorization": "Bearer secret"}, + session=fake_session, + ) + except OutboundHttpPolicyError: + pass + else: + raise AssertionError("Cross-origin redirect should have been rejected.") + + assert len(fake_session.requests) == 1 + + +if __name__ == "__main__": + tests = [ + test_version_contract, + test_structural_url_policy, + test_dns_policy_rejects_any_non_public_answer, + test_same_origin_service_policy, + test_request_policy_allows_same_origin_redirects, + test_request_policy_blocks_cross_origin_redirects_before_credentials_are_sent, + ] + results = [] + for test in tests: + try: + test() + print(f"PASS {test.__name__}") + results.append(True) + except Exception as error: + print(f"FAIL {test.__name__}: {error}") + results.append(False) + print(f"\nResults: {sum(results)}/{len(results)} tests passed") + sys.exit(0 if all(results) else 1)