Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions application/single_app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Check warning on line 37 in application/single_app/config.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains security control, sanitization, or audit marker. Recommendation%3A Confirm the change does not weaken auth, CSRF, CSP, XSS defenses, settings sanitization, redaction, audit logging, or tests.

Check warning on line 37 in application/single_app/config.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains secret or sensitive data source marker. Recommendation%3A Pair this source with any nearby network, logging, serialization, or process execution sink before approving.

from functions_environment import load_simplechat_dotenv
from flask import (
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -519,7 +520,9 @@
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)

Check warning on line 523 in application/single_app/config.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains security control, sanitization, or audit marker. Recommendation%3A Confirm the change does not weaken auth, CSRF, CSP, XSS defenses, settings sanitization, redaction, audit logging, or tests.

Check warning on line 523 in application/single_app/config.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains secret or sensitive data source marker. Recommendation%3A Pair this source with any nearby network, logging, serialization, or process execution sink before approving.
# codeql[py/full-ssrf]
return BlobServiceClient(account_url=safe_blob_endpoint, credential=DefaultAzureCredential())

Check warning on line 525 in application/single_app/config.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains secret or sensitive data source marker. Recommendation%3A Pair this source with any nearby network, logging, serialization, or process execution sink before approving.

connection_string = str(settings.get("office_docs_storage_account_url") or "").strip()
if not connection_string:
Expand Down
16 changes: 12 additions & 4 deletions application/single_app/functions_action_connection_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import requests

from functions_appinsights import log_event
from functions_azure_endpoint_validation import validate_azure_maps_endpoint

Check warning on line 23 in application/single_app/functions_action_connection_tests.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains security control, sanitization, or audit marker. Recommendation%3A Confirm the change does not weaken auth, CSRF, CSP, XSS defenses, settings sanitization, redaction, audit logging, or tests.
from functions_azure_maps import (
AZURE_MAPS_DEFAULT_ENDPOINT,
AZURE_MAPS_DEFAULT_LANGUAGE,
Expand All @@ -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

Check warning on line 32 in application/single_app/functions_action_connection_tests.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains security control, sanitization, or audit marker. Recommendation%3A Confirm the change does not weaken auth, CSRF, CSP, XSS defenses, settings sanitization, redaction, audit logging, or tests.

ACTION_CONNECTION_TEST_MAX_TIMEOUT_SECONDS = 20
ACTION_CONNECTION_TEST_MIN_TIMEOUT_SECONDS = 1
Expand Down Expand Up @@ -228,15 +230,15 @@

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:

Check warning on line 241 in application/single_app/functions_action_connection_tests.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains security control, sanitization, or audit marker. Recommendation%3A Confirm the change does not weaken auth, CSRF, CSP, XSS defenses, settings sanitization, redaction, audit logging, or tests.

Check warning on line 241 in application/single_app/functions_action_connection_tests.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains external connection or remote asset marker. Recommendation%3A Review whether changed code can send prompts, files, credentials, cookies, settings, logs, or user data to a new sink.
result = build_failure_result(
f"The API base URL could not be reached: {sanitize_connection_error(exc, manifest)}",
status=502,
Expand Down Expand Up @@ -271,12 +273,17 @@
"""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:

Check warning on line 280 in application/single_app/functions_action_connection_tests.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains security control, sanitization, or audit marker. Recommendation%3A Confirm the change does not weaken auth, CSRF, CSP, XSS defenses, settings sanitization, redaction, audit logging, or tests.
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={
Expand All @@ -291,6 +298,7 @@
"subscription-key": subscription_key,
},
timeout=ACTION_CONNECTION_TEST_DEFAULT_TIMEOUT_SECONDS,
allow_redirects=False,
)
except requests.RequestException as exc:
result = build_failure_result(
Expand Down
90 changes: 89 additions & 1 deletion application/single_app/functions_azure_endpoint_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand Down Expand Up @@ -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"
Expand All @@ -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 "
Expand All @@ -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:
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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}"
79 changes: 67 additions & 12 deletions application/single_app/functions_content_understanding.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 = ''
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -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))
Expand Down Expand Up @@ -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."

Expand All @@ -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}"
Expand Down
Loading
Loading