Skip to content
Merged
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
2 changes: 1 addition & 1 deletion application/single_app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@
EXECUTOR_TYPE = 'thread'
EXECUTOR_MAX_WORKERS = 30
SESSION_TYPE = 'filesystem'
VERSION = "0.260.017"
VERSION = "0.260.018"
IS_DEVELOPMENT = is_development_env_enabled()

SESSION_COOKIE_SAMESITE = os.getenv('SESSION_COOKIE_SAMESITE', 'Lax')
Expand Down
250 changes: 250 additions & 0 deletions application/single_app/functions_azure_endpoint_validation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
# functions_azure_endpoint_validation.py
"""Shared Azure endpoint origin validation for actions that use the application identity.

Actions can be configured with a caller-supplied endpoint while authenticating with the
application's own workload identity. Without an origin check, a caller who can save an
action is able to direct an application-identity token to a destination they control.
Each validator here rejects endpoints that are not canonical Azure service hostnames for
a supported Azure cloud and returns a rebuilt origin, so only the validated destination
ever reaches an SDK client.

This module is intentionally dependency-light so manifest validation, plugin construction,
and File Sync can all import it without creating import cycles.
"""

import ipaddress
import re
from typing import Any, Iterable, Tuple
from urllib.parse import ParseResult, urlparse

# Azure Storage service suffixes for the public, US Government, China, and Germany clouds.
AZURE_STORAGE_ENDPOINT_SUFFIXES = (
"core.windows.net",
"core.usgovcloudapi.net",
"core.chinacloudapi.cn",
"core.cloudapi.de",
)
AZURE_COSMOS_ENDPOINT_SUFFIXES = (
"documents.azure.com",
"documents.azure.us",
"documents.azure.cn",
"documents.microsoftazure.de",
)
AZURE_DATABRICKS_ENDPOINT_SUFFIXES = (
"azuredatabricks.net",
"databricks.azure.us",
"databricks.azure.cn",
)
AZURE_MONITOR_QUERY_HOSTS = (
"api.loganalytics.io",
"api.loganalytics.us",
"api.loganalytics.azure.cn",
)
# Mirrors azure.identity.AzureAuthorityHosts so a caller cannot select a token authority.
AZURE_ENTRA_AUTHORITY_HOSTS = (
"login.microsoftonline.com",
"login.microsoftonline.us",
"login.chinacloudapi.cn",
"login.microsoftonline.de",
)

AZURE_BLOB_SERVICE_LABEL = "blob"
AZURE_QUEUE_SERVICE_LABEL = "queue"
AZURE_FILE_SERVICE_LABEL = "file"

MAX_ENDPOINT_LENGTH = 2048
STORAGE_ACCOUNT_LABEL_PATTERN = re.compile(r"^[a-z0-9]{3,24}$")
DNS_LABEL_PATTERN = re.compile(r"^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$")
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])?)*$"
)

AZURE_BLOB_ENDPOINT_ERROR = (
"Blob Storage actions require an HTTPS Azure Blob service endpoint such as "
"https://account.blob.core.windows.net"
)
AZURE_QUEUE_ENDPOINT_ERROR = (
"Queue Storage actions require an HTTPS Azure Queue service endpoint such as "
"https://account.queue.core.windows.net"
)
AZURE_COSMOS_ENDPOINT_ERROR = (
"Cosmos actions require an HTTPS Azure Cosmos DB endpoint such as "
"https://account.documents.azure.com"
)
AZURE_DATABRICKS_ENDPOINT_ERROR = (
"Databricks actions require an HTTPS Azure Databricks workspace endpoint such as "
"https://adb-0000000000000000.0.azuredatabricks.net"
)
AZURE_MONITOR_QUERY_ENDPOINT_ERROR = (
"Log Analytics actions require a supported Azure Monitor query endpoint such as "
"https://api.loganalytics.io"
)
AZURE_AUTHORITY_HOST_ERROR = (
"Log Analytics actions require a supported Microsoft Entra authority host such as "
"login.microsoftonline.com"
)


def _normalize_endpoint_text(value: Any) -> str:
return str(value or "").strip()[:MAX_ENDPOINT_LENGTH]


def _reject_unsafe_host(hostname: str, error_message: str) -> None:
"""Reject local hostnames and IP literals, which are never Azure service endpoints."""
normalized_host = hostname.strip("[]")
if (
normalized_host in {"localhost", "localhost.localdomain"}
or normalized_host.endswith(".localhost")
):
raise ValueError(error_message)

try:
ipaddress.ip_address(normalized_host)
except ValueError:
return
raise ValueError(error_message)


def parse_azure_https_endpoint(
value: Any,
error_message: str,
allow_default_port: bool = False,
) -> Tuple[ParseResult, str]:
"""Structurally validate an HTTPS endpoint and return its parsed form and hostname.

Rejects non-HTTPS schemes, embedded credentials, explicit ports, query strings,
parameters, fragments, local hostnames, and IP literals. A bare hostname is accepted
and treated as HTTPS so stored values without a scheme still validate.
"""
raw_url = _normalize_endpoint_text(value)
if not raw_url:
raise ValueError(error_message)
if "://" not in raw_url:
raw_url = f"https://{raw_url}"

parsed_url = urlparse(raw_url)
try:
parsed_port = parsed_url.port
except ValueError as error:
raise ValueError(error_message) from error

if parsed_port is not None and not (allow_default_port and parsed_port == 443):
raise ValueError(error_message)

if (
parsed_url.scheme != "https"
or not parsed_url.hostname
or parsed_url.username is not None
or parsed_url.password is not None
or parsed_url.query
or parsed_url.fragment
or parsed_url.params
):
raise ValueError(error_message)

hostname = parsed_url.hostname.lower()
_reject_unsafe_host(hostname, error_message)
return parsed_url, hostname


def _match_endpoint_suffix(
hostname: str,
service_label: str,
endpoint_suffixes: Iterable[str],
error_message: str,
) -> Tuple[str, str]:
"""Return the resource label and matched Azure cloud suffix for a hostname."""
for endpoint_suffix in endpoint_suffixes:
host_suffix = f".{service_label}.{endpoint_suffix}" if service_label else f".{endpoint_suffix}"
if not hostname.endswith(host_suffix):
continue
resource_label = hostname[: -len(host_suffix)]
if resource_label:
return resource_label, endpoint_suffix
raise ValueError(error_message)


def azure_storage_endpoint_suffix_for_hostname(
hostname: Any,
service_label: str = AZURE_BLOB_SERVICE_LABEL,
error_message: str = AZURE_BLOB_ENDPOINT_ERROR,
) -> Tuple[str, str]:
"""Return the storage account name and Azure cloud suffix for a service hostname."""
normalized_hostname = str(hostname or "").strip().lower()
account_name, endpoint_suffix = _match_endpoint_suffix(
normalized_hostname,
service_label,
AZURE_STORAGE_ENDPOINT_SUFFIXES,
error_message,
)
if not STORAGE_ACCOUNT_LABEL_PATTERN.match(account_name):
raise ValueError(error_message)
return account_name, endpoint_suffix


def _validate_storage_endpoint(value: Any, service_label: str, error_message: str) -> str:
_, hostname = parse_azure_https_endpoint(value, error_message)
account_name, endpoint_suffix = azure_storage_endpoint_suffix_for_hostname(
hostname,
service_label,
error_message,
)
return f"https://{account_name}.{service_label}.{endpoint_suffix}"


def validate_azure_blob_endpoint(value: Any) -> str:
"""Return a canonical Azure Blob service origin, or raise ValueError."""
return _validate_storage_endpoint(value, AZURE_BLOB_SERVICE_LABEL, AZURE_BLOB_ENDPOINT_ERROR)


def validate_azure_queue_endpoint(value: Any) -> str:
"""Return a canonical Azure Queue service origin, or raise ValueError."""
return _validate_storage_endpoint(value, AZURE_QUEUE_SERVICE_LABEL, AZURE_QUEUE_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(
value,
AZURE_COSMOS_ENDPOINT_ERROR,
allow_default_port=True,
)
account_name, endpoint_suffix = _match_endpoint_suffix(
hostname,
"",
AZURE_COSMOS_ENDPOINT_SUFFIXES,
AZURE_COSMOS_ENDPOINT_ERROR,
)
if not DNS_LABEL_PATTERN.match(account_name):
raise ValueError(AZURE_COSMOS_ENDPOINT_ERROR)
return f"https://{account_name}.{endpoint_suffix}"


def validate_azure_databricks_endpoint(value: Any) -> str:
"""Return a canonical Azure Databricks workspace origin, or raise ValueError."""
_, hostname = parse_azure_https_endpoint(value, AZURE_DATABRICKS_ENDPOINT_ERROR)
workspace_label, endpoint_suffix = _match_endpoint_suffix(
hostname,
"",
AZURE_DATABRICKS_ENDPOINT_SUFFIXES,
AZURE_DATABRICKS_ENDPOINT_ERROR,
)
if not DNS_NAME_PATTERN.match(workspace_label):
raise ValueError(AZURE_DATABRICKS_ENDPOINT_ERROR)
return f"https://{workspace_label}.{endpoint_suffix}"


def validate_azure_monitor_query_endpoint(value: Any) -> str:
"""Return a supported Azure Monitor query endpoint origin, or raise ValueError."""
_, hostname = parse_azure_https_endpoint(value, AZURE_MONITOR_QUERY_ENDPOINT_ERROR)
if hostname not in AZURE_MONITOR_QUERY_HOSTS:
raise ValueError(AZURE_MONITOR_QUERY_ENDPOINT_ERROR)
return f"https://{hostname}"


def validate_azure_entra_authority_host(value: Any) -> str:
"""Return a supported Microsoft Entra authority hostname, or raise ValueError."""
_, hostname = parse_azure_https_endpoint(value, AZURE_AUTHORITY_HOST_ERROR)
if hostname not in AZURE_ENTRA_AUTHORITY_HOSTS:
raise ValueError(AZURE_AUTHORITY_HOST_ERROR)
return hostname
23 changes: 8 additions & 15 deletions application/single_app/functions_file_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@
)
from functions_appinsights import log_event
from functions_authentication import get_graph_authority, get_graph_base_url, get_graph_endpoint
from functions_azure_endpoint_validation import (
AZURE_STORAGE_ENDPOINT_SUFFIXES,
azure_storage_endpoint_suffix_for_hostname,
)
from functions_debug import debug_print
from functions_documents import (
allowed_file,
Expand Down Expand Up @@ -110,12 +114,6 @@
FILE_SYNC_SOURCE_TYPE_SHAREPOINT_ON_PREM: "On-prem SharePoint",
FILE_SYNC_SOURCE_TYPE_GOOGLE_WORKSPACE: "Google Workspace",
}
AZURE_STORAGE_ENDPOINT_SUFFIXES = (
"core.windows.net",
"core.usgovcloudapi.net",
"core.chinacloudapi.cn",
"core.cloudapi.de",
)
AZURE_BLOB_SAS_PERMISSION_LABELS = {
"r": "Read",
"l": "List",
Expand Down Expand Up @@ -768,15 +766,10 @@ def _normalize_azure_files_connection(


def _azure_blob_endpoint_suffix_for_hostname(hostname: Any) -> Tuple[str, str]:
normalized_hostname = str(hostname or "").strip().lower()
for endpoint_suffix in AZURE_STORAGE_ENDPOINT_SUFFIXES:
blob_hostname_suffix = f".blob.{endpoint_suffix}"
if not normalized_hostname.endswith(blob_hostname_suffix):
continue
account_name = normalized_hostname[:-len(blob_hostname_suffix)]
if 3 <= len(account_name) <= 24 and account_name.isascii() and account_name.isalnum():
return account_name, endpoint_suffix
raise ValueError("Azure Blob Storage endpoint must use a supported Azure Blob service hostname")
return azure_storage_endpoint_suffix_for_hostname(
hostname,
error_message="Azure Blob Storage endpoint must use a supported Azure Blob service hostname",
)


def _normalize_azure_blob_url(value: Any) -> Tuple[str, List[str]]:
Expand Down
Loading