Skip to content
Open
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 @@ -103,7 +103,7 @@
EXECUTOR_MAX_WORKERS = 30
CHAT_STREAM_EXECUTOR_MAX_WORKERS = max(8, int(os.getenv('SIMPLECHAT_CHAT_STREAM_WORKERS', '32') or '32'))
SESSION_TYPE = 'filesystem'
VERSION = "0.261.434"
VERSION = "0.261.436"
IS_DEVELOPMENT = is_development_env_enabled()

# Opt-out for deployments where App Service Easy Auth is active but the platform
Expand Down
25 changes: 22 additions & 3 deletions application/single_app/functions_action_connection_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
import requests

from functions_appinsights import log_event
from functions_outbound_destinations import OutboundDestinationError
from functions_outbound_http import guarded_probe_request
from functions_azure_maps import (
AZURE_MAPS_DEFAULT_ENDPOINT,
AZURE_MAPS_DEFAULT_LANGUAGE,
Expand Down Expand Up @@ -228,14 +230,20 @@

probe = _build_openapi_probe_request(manifest)
try:
response = requests.get(
response = guarded_probe_request(
"GET",
base_url,
max_bytes=0,
headers=probe["headers"],
params=probe["params"] or None,
auth=probe["basic_auth"],
timeout=timeout,
allow_redirects=True,
)
except OutboundDestinationError as exc:
result = build_failure_result(exc.public_message)
_log_connection_test("openapi", result, {"stage": "destination_policy"})

Check warning on line 245 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 AI, plugin, agent, or workspace boundary marker. Recommendation%3A Check whether prompts, chat history, uploaded documents, embeddings, citations, settings, or identity can cross a new boundary.

Check warning on line 245 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.
return result
except requests.RequestException as exc:
result = build_failure_result(
f"The API base URL could not be reached: {sanitize_connection_error(exc, manifest)}",
Expand Down Expand Up @@ -277,8 +285,10 @@
return build_failure_result("An Azure Maps subscription key is required before testing this action.")

try:
response = requests.get(
response = guarded_probe_request(
"GET",
f"{endpoint}/map/tile",
max_bytes=0,
params={
"api-version": AZURE_MAPS_TILE_API_VERSION,
"tilesetId": AZURE_MAPS_DEFAULT_TILESET_ID,
Expand All @@ -292,6 +302,10 @@
},
timeout=ACTION_CONNECTION_TEST_DEFAULT_TIMEOUT_SECONDS,
)
except OutboundDestinationError as exc:
result = build_failure_result(exc.public_message)
_log_connection_test("azure_maps_openlayers", result, {"stage": "destination_policy"})

Check warning on line 307 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.
return result
except requests.RequestException as exc:
result = build_failure_result(
f"Azure Maps could not be reached: {sanitize_connection_error(exc, manifest)}",
Expand Down Expand Up @@ -423,11 +437,16 @@
return result

try:
response = requests.get(
response = guarded_probe_request(
"GET",
f"{plugin.endpoint}/api/2.0/sql/warehouses/{plugin.warehouse_id}",
headers=headers,
timeout=timeout,
)
except OutboundDestinationError as exc:
result = build_failure_result(exc.public_message)
_log_connection_test("databricks", result, {"stage": "destination_policy"})

Check warning on line 448 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.
return result
except requests.RequestException as exc:
result = build_failure_result(
f"The Databricks workspace could not be reached: {sanitize_connection_error(exc, manifest)}",
Expand Down
8 changes: 8 additions & 0 deletions application/single_app/functions_mcp_destinations.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
session = None

from functions_appinsights import log_event
from functions_outbound_destinations import OutboundDestinationError, normalize_outbound_url
from functions_action_manifest import (
McpActionOrigin,
McpConfigurationError,
Expand Down Expand Up @@ -709,6 +710,13 @@
normalized_scope, normalized_scope_id = resolve_mcp_destination_scope(
manifest, scope_type, scope_id, origin=origin
)
try:

Check warning on line 713 in application/single_app/functions_mcp_destinations.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.
normalize_outbound_url(descriptor["normalized_endpoint"])
except OutboundDestinationError as exc:
return {
"allowed": False, "reason": exc.public_message, "descriptor": descriptor,
"matched_pattern": "", "scope_type": normalized_scope, "scope_id": normalized_scope_id,
}
policy = policy_config if policy_config is not None else get_mcp_destination_policy_config(user_id=user_id)
environment_decision = _evaluate_destination_descriptor_policy(
descriptor, normalized_scope, normalized_scope_id, _get_environment_destination_policy_config()
Expand Down
18 changes: 17 additions & 1 deletion application/single_app/functions_mcp_operations.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from jsonschema.exceptions import SchemaError

from functions_action_manifest import McpConfigurationError, McpStdioRemovedError
from functions_outbound_destinations import OutboundDestinationError
from functions_mcp_fingerprint_metadata import (
MCP_PROMPTS_FIELD,
MCP_TOOL_DRIFT_FIELD,
Expand Down Expand Up @@ -340,6 +341,21 @@

def classify_mcp_exception(exc, operation="mcp"):
"""Classify MCP exceptions into safe, actionable categories."""
pending = [exc]
visited = set()
while pending and len(visited) < 32:
candidate = pending.pop()
if id(candidate) in visited:
continue
visited.add(id(candidate))
if isinstance(candidate, OutboundDestinationError):
return {
"category": "destination_policy", "message": candidate.public_message,

Check warning on line 353 in application/single_app/functions_mcp_operations.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.
"detail": candidate.public_message, "operation": operation, "retryable": False,
}
pending.extend(getattr(candidate, "exceptions", ()))

Check warning on line 356 in application/single_app/functions_mcp_operations.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Moderate - Changed line contains obfuscation, dynamic loading, or hidden payload marker. Recommendation%3A Confirm the changed code is not hiding behavior, decoding payloads, or bypassing normal review.
if candidate.__cause__ is not None:
pending.append(candidate.__cause__)
detail = redact_mcp_error_detail(str(exc))
detail_lower = detail.lower()
operation_text = str(operation or "mcp").strip() or "mcp"
Expand Down Expand Up @@ -401,7 +417,7 @@

def get_mcp_error_http_status(category):
"""Map an MCP error category to an HTTP status suitable for discovery responses."""
if category in {"validation", "mcp_stdio_removed"}:
if category in {"validation", "mcp_stdio_removed", "destination_policy"}:

Check warning on line 420 in application/single_app/functions_mcp_operations.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.
return 400
if category == "authorization":
return 403
Expand Down
216 changes: 216 additions & 0 deletions application/single_app/functions_outbound_destinations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
# functions_outbound_destinations.py
"""Bootstrap-free destination rules for user-configurable action connections."""

import ipaddress
import re
import socket
from dataclasses import dataclass
from urllib.parse import urljoin, urlsplit, urlunsplit

Check warning on line 8 in application/single_app/functions_outbound_destinations.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.

from functions_action_manifest import resolve_action_type


_SCHEME_PORTS = {"http": 80, "https": 443, "ws": 80, "wss": 443}
_METADATA_ADDRESSES = frozenset({
ipaddress.ip_address("168.63.129.16"),
ipaddress.ip_address("100.100.100.200"),
ipaddress.ip_address("fd00:ec2::254"),
})
_THIS_NETWORK = ipaddress.ip_network("0.0.0.0/8")
_NAT64 = ipaddress.ip_network("64:ff9b::/96")
_HOST_LABEL = re.compile(r"[a-z0-9_](?:[a-z0-9_-]{0,61}[a-z0-9_])?")

Check warning on line 21 in application/single_app/functions_outbound_destinations.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Moderate - Changed line contains obfuscation, dynamic loading, or hidden payload marker. Recommendation%3A Confirm the changed code is not hiding behavior, decoding payloads, or bypassing normal review.
_NUMERIC_HOST = re.compile(r"(?:[0-9.]+|0x[0-9a-f]+)", re.IGNORECASE)
_HTTP_ACTION_TYPES = {
"openapi", "rocksdb", "yamcs", "tableau", "databricks", "azure_maps_openlayers", "azure_function", "mcp",
}


class OutboundDestinationError(ValueError):
"""A stable, non-secret action connection policy error."""

def __init__(self, message):
super().__init__(message)
self.public_message = message


@dataclass(frozen=True)
class OutboundTarget:
url: str
scheme: str
host: str
port: int

@property
def origin(self):
return self.scheme, self.host, self.port


def validate_outbound_address(value):
"""Allow enterprise private routing, but never local/infrastructure targets."""
try:
address = ipaddress.ip_address(value)
except ValueError as exc:
raise OutboundDestinationError("The action destination has an invalid IP address.") from exc

if address in _METADATA_ADDRESSES:
raise OutboundDestinationError("Cloud infrastructure destinations are not permitted for actions.")
if isinstance(address, ipaddress.IPv6Address):
embedded = address.ipv4_mapped or address.sixtofour
if address in _NAT64:
embedded = ipaddress.IPv4Address(int(address) & 0xFFFFFFFF)
if address.teredo:
for teredo_address in address.teredo:
validate_outbound_address(str(teredo_address))
if embedded is not None:
validate_outbound_address(str(embedded))
return
if (
address.is_loopback or address.is_link_local or address.is_unspecified
or address.is_multicast or address.is_reserved
or (isinstance(address, ipaddress.IPv4Address) and address in _THIS_NETWORK)
):
raise OutboundDestinationError(
"Localhost, loopback, link-local and reserved destinations are not permitted for actions."
)


def _normalize_host(value):
host = value.lower()
if host.endswith("..") or "%" in host:
raise OutboundDestinationError("The action destination has an invalid hostname.")
host = host.removesuffix(".")
if host == "localhost" or host.endswith(".localhost") or host == "localhost.localdomain":
raise OutboundDestinationError("Localhost destinations are not permitted for actions.")
try:
address = ipaddress.ip_address(host)
except ValueError:
if _NUMERIC_HOST.fullmatch(host):
raise OutboundDestinationError("Noncanonical numeric action destinations are not permitted.")
try:
host = host.encode("idna").decode("ascii")
except UnicodeError as exc:
raise OutboundDestinationError("The action destination has an invalid hostname.") from exc
if len(host) > 253 or not all(_HOST_LABEL.fullmatch(label) for label in host.split(".")):
raise OutboundDestinationError("The action destination has an invalid hostname.")
else:
validate_outbound_address(str(address))
host = str(address)
return host


def normalize_outbound_url(value):
"""Validate URL structure and literal destinations without performing DNS."""
if not isinstance(value, str) or not value.strip() or len(value) > 8192:
raise OutboundDestinationError("An HTTP or remote MCP service URL is required.")
value = value.strip()
if "\\" in value or any(ord(character) <= 32 or ord(character) == 127 for character in value):
raise OutboundDestinationError("The action destination URL is invalid.")
try:
parsed = urlsplit(value)
if (
parsed.scheme.lower() not in _SCHEME_PORTS or not parsed.hostname
or parsed.username is not None or parsed.password is not None
):
raise OutboundDestinationError("The action destination URL or scheme is not permitted.")
host = _normalize_host(parsed.hostname)
scheme = parsed.scheme.lower()
port = parsed.port if parsed.port is not None else _SCHEME_PORTS[scheme]
except ValueError as exc:
if isinstance(exc, OutboundDestinationError):
raise
raise OutboundDestinationError("The action destination URL is invalid.") from exc
if not 1 <= port <= 65535:
raise OutboundDestinationError("The action destination port is invalid.")
authority = f"[{host}]" if ":" in host else host
if port != _SCHEME_PORTS[scheme]:
authority = f"{authority}:{port}"
return OutboundTarget(
urlunsplit((scheme, authority, parsed.path or "/", parsed.query, "")), scheme, host, port,
)


def resolve_outbound_addresses(host, port):
"""Resolve all candidates once; callers must connect to a returned numeric IP."""
host = _normalize_host(host)
try:
addresses = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)
except OSError as exc:
raise OutboundDestinationError("The action destination could not be resolved.") from exc
result = []
for family, _, _, _, sockaddr in addresses:
if family not in (socket.AF_INET, socket.AF_INET6):
raise OutboundDestinationError("The action destination address family is not supported.")
address = sockaddr[0]
validate_outbound_address(address)
if address not in result:
result.append(address)
if not result:
raise OutboundDestinationError("The action destination did not resolve to a usable address.")
return tuple(result)


def validate_outbound_redirect(source, location):
"""Permit same-origin redirects and default-port TLS upgrades, never retargeting."""
before = normalize_outbound_url(source)
after = normalize_outbound_url(urljoin(before.url, location))
tls_upgrade = (
before.scheme in {"http", "ws"} and after.scheme == f"{before.scheme}s"
and before.host == after.host and before.port == 80 and after.port == 443
)
if before.origin != after.origin and not tls_upgrade:
raise OutboundDestinationError(
"Cross-origin action redirects are not permitted. Configure the final service URL."
)
return after.url


def validate_outbound_headers(url, headers):
target = normalize_outbound_url(str(url))
authority = f"[{target.host}]" if ":" in target.host else target.host
if target.port != _SCHEME_PORTS[target.scheme]:
authority = f"{authority}:{target.port}"
for name, value in (headers or {}).items():
if name.lower() == "host":
if any(character in str(value) for character in "/?#@\\"):
raise OutboundDestinationError("Action headers cannot override the destination authority.")
supplied = normalize_outbound_url(f"{target.scheme}://{value}")
if supplied.origin != target.origin or supplied.url != f"{target.scheme}://{authority}/":
raise OutboundDestinationError("Action headers cannot override the destination authority.")
if name.lower() == "proxy-authorization":
raise OutboundDestinationError("Forward-proxy credentials cannot be supplied as action headers.")
return target


def action_connection_signature(manifest):
"""Compare the effective connector and origin without resolving DNS or secrets."""
plugin_type = resolve_action_type(manifest)
fields = manifest.get("additionalFields", {})
if not isinstance(fields, dict):
raise OutboundDestinationError("Action additional fields must be an object.")
endpoint = manifest.get("endpoint")
if plugin_type == "rocksdb":
endpoint = fields.get("base_url") or endpoint
elif plugin_type == "openapi":
endpoint = manifest.get("base_url") or endpoint or fields.get("base_url")
elif plugin_type in {"yamcs", "tableau"}:
endpoint = endpoint or fields.get("server_url") or fields.get("serverUrl")
if endpoint and "://" not in endpoint:
endpoint = f"https://{endpoint}"
elif plugin_type in {"databricks", "databricks_table"}:
endpoint = endpoint or fields.get("workspace_url") or fields.get("workspaceUrl")
plugin_type = "databricks"
elif plugin_type == "azure_maps_openlayers":
endpoint = endpoint or "https://atlas.microsoft.com"
if not endpoint:
return plugin_type, None
if not isinstance(endpoint, str):
raise OutboundDestinationError("The action destination URL is invalid.")
try:
scheme = urlsplit(endpoint).scheme.lower()
except ValueError as exc:
raise OutboundDestinationError("The action destination URL is invalid.") from exc
if scheme not in _SCHEME_PORTS:
if plugin_type in _HTTP_ACTION_TYPES:
raise OutboundDestinationError("This action requires an HTTP or remote MCP service URL.")
return plugin_type, None
return plugin_type, normalize_outbound_url(endpoint).origin
Loading
Loading