mirror of
https://github.com/microsoft/semantic-kernel.git
synced 2026-10-12 02:19:06 +00:00
### Motivation and Context Fixes #14312. `validate_server_url` (`connectors/openapi_plugin/server_url_validator.py`) is a deliberate anti-SSRF control: it resolves the operation host and blocks private, loopback, link-local and metadata addresses. It then returned `None`, discarding the addresses it had just vetted. `OpenApiRunner.run_operation` called it and afterwards issued the request against the *hostname* via `httpx.AsyncClient(...).request(url=...)`, so httpx resolved the name a second time when opening the connection. A name that resolves to a public address during validation and to a private one at connect time — classic DNS rebinding — passed the check and was then contacted. `run_operation` attaches `auth_callback` credentials to that request. **Severity, stated without inflation.** This is hardening, not a high-severity SSRF, and the issue author already said so. On the default path the validator forces `https` and httpx verifies certificates, so a rebind to e.g. `169.254.169.254` fails the TLS handshake: the residual is a blind TCP connect + ClientHello to an internal address, not credential disclosure. Reaching actual disclosure requires an operator-configured `http` `allowed_base_urls` entry, a caller-supplied client with `verify=False`, or a host platform ingesting untrusted OpenAPI specs. The feature is `@experimental`. It is worth closing because the validator exists precisely to stop this, and this is its one check-time/use-time gap. ### Description - `validate_server_url` now returns the addresses it actually vetted, in resolver order. This is additive — it previously returned `None`, so existing callers are unaffected. - The runner's built-in client sends the request to one of those addresses: the URL carries the address, the `Host` header and the `sni_hostname` extension carry the original hostname. TLS verification therefore still runs against the hostname (httpcore passes `sni_hostname` through as `server_hostname` for the handshake) and the bytes on the wire are unchanged. `httpx.URL.copy_with(host=...)` preserves IPv6 bracketing, the port and userinfo. - Remaining vetted addresses are tried if a connection cannot be established, preserving the resolver's A/AAAA fallback. Only `ConnectError`/`ConnectTimeout` are retried, so a request that may already be on the wire is never resent. - No new module, no new dependency, no custom transport, no private httpx/httpcore API in shipped code. `sni_hostname` is httpx's documented extension for exactly this case. Nothing is pinned where no DNS validation took place: an `allowed_base_urls` match, `allow_private_network_access`, or a literal IP host (which cannot be rebound). For context, #14317 attempted this with a custom `PinnedDnsTransport` that re-implemented httpx's pool and proxy construction; it was self-closed unmerged with two review findings still open (environment proxies bypassed, and only the first resolved address used). This change avoids the transport entirely and closes both of those points. ### What this does NOT cover - **Caller-supplied `http_client`** is not pinned. That client owns its transport — proxies, mounts, custom resolvers, `base_url` — and forcing an IP through it can break proxying and split-horizon deployments. Its requests use its own name resolution and remain exposed to the rebinding gap. - **Environment proxies** disable pinning on the default path too. A proxy resolves the target name itself, so an address resolved locally is neither used for the connection nor necessarily correct from the proxy's vantage point. The check is deliberately conservative: any configured `http`/`https`/`all` proxy turns pinning off, and `NO_PROXY` is not parsed. - **The `allowed_base_urls` path** still matches on hostname strings without resolving, as before. Adding resolution there is a policy change for operators who opted in explicitly, so it is left for a separate discussion. - **Redirects are not re-validated.** The built-in client uses httpx's default `follow_redirects=False`, so this is not reachable there; a caller-supplied client that enables redirects can still be redirected to an unvalidated host. ### Tests New `tests/unit/connectors/openapi_plugin/test_openapi_runner_dns_pinning.py` (12 tests): | Test | What it proves | | --- | --- | | `..._pins_connection_to_validated_address_under_dns_rebinding` | Drives real httpx + httpcore with only the network backend recorded. First resolution returns a public address, later ones return `169.254.169.254`. Asserts the socket is opened against the vetted address, the TLS SNI is the original hostname, `Host:` on the wire is the original hostname, and the host is resolved exactly once. | | `..._pins_request_url_and_preserves_host_identity` | Request URL is the vetted IP; `Host` and `sni_hostname` are the hostname. | | `..._pins_first_validated_address_when_several_are_returned` | The resolver's preferred address is used, not an arbitrary one. | | `..._falls_back_to_the_next_validated_address_on_connect_error` | A connect failure falls through to the remaining vetted addresses, in order. | | `..._does_not_retry_a_request_that_may_already_have_been_delivered` | A read timeout is not retried against a second address, so the request is not delivered twice. | | `..._brackets_ipv6_address_and_preserves_the_port` | IPv6 pin stays a parseable URL, and the port survives in both the URL and the `Host` header. | | `..._does_not_pin_when_an_allowed_base_url_matches` | Allowed-base-url path is untouched. | | `..._does_not_pin_when_private_network_access_is_allowed` | The private-network opt-in is not silently overridden. | | `..._does_not_pin_a_literal_ip_host` | A literal address is left exactly as it was. | | `..._does_not_pin_when_an_environment_proxy_is_configured` | Proxy users keep their existing routing. | | `..._does_not_pin_a_caller_supplied_client` | A supplied client's requests are unmodified. | | `..._still_blocks_a_host_that_resolves_to_a_private_address` | Pinning did not weaken the existing block. | Plus 5 tests in `test_server_url_validator.py` covering the return contract: vetted IPv4 and IPv6 lists, and the empty list for allowed-base-url, private-network opt-in and literal-IP hosts. Every new assertion-bearing test was confirmed failing on the unfixed code before it passed on the fixed code — 11 of them fail on `main`, the rebinding one with `connection was opened against 169.254.169.254, not the validated address`. The "does not pin" guards assert unchanged behaviour and so cannot go red against `main`; each was instead validated by deliberately weakening the fix (pin IPv4 only; drop the SNI extension; drop the `Host` header; drop the port from `Host`; pin the wrong list element; pin despite a proxy; naive URL build; pin a literal IP; pin despite `allow_private_network_access`; pin on the `allowed_base_urls` path; pin a caller-supplied client; retry on any error rather than connection errors) — every weakening was caught. The last two of those weakenings were found during an independent verification pass, and the read-timeout test above was added because that pass showed nothing yet proved the no-double-delivery claim. ``` uv run pytest tests/unit/connectors/openapi_plugin/ 200 passed in 5.60s uv run ruff check semantic_kernel tests All checks passed! (ruff 0.9.6, the version .pre-commit-config.yaml pins) uv run ruff format --check <changed files> already formatted uv run mypy semantic_kernel/connectors/openapi_plugin Success: no issues found in 22 source files uv run pytest tests/unit 3069 passed (baseline on pristine main 3052; +17 = exactly the new tests) ``` The broader `tests/unit` run has 17 pre-existing failures (16 ONNX, 1 OpenAI text-to-image) and 42 collection errors from optional extras that could not be installed on the machine used here (`torch` publishes no x86_64 macOS wheel). Both were measured on pristine `main` as well and the failure sets are identical with and without this change; no dependency pin was modified. ### Contribution Checklist - [x] The code builds clean without any errors or warnings - [x] The PR follows the [SK Contribution Guidelines](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md) - [x] I didn't break anyone 😄 Authored by Mycroft, the synthetic co-founder at Anton Dzyatkovsky's lab (autonomous mode; named responsible person: Anton Dziatkovskii). The test runs above were independently re-executed before submission. --------- Signed-off-by: tonydzi <[email protected]> Co-authored-by: Anton Dziatkovskii <[email protected]> Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
282 lines
13 KiB
Python
282 lines
13 KiB
Python
# Copyright (c) Microsoft. All rights reserved.
|
|
|
|
import ipaddress
|
|
import json
|
|
import logging
|
|
from collections import OrderedDict
|
|
from collections.abc import Awaitable, Callable, Mapping
|
|
from inspect import isawaitable
|
|
from typing import Any
|
|
from urllib.parse import urlparse, urlunparse
|
|
from urllib.request import getproxies, proxy_bypass
|
|
|
|
import httpx
|
|
from openapi_core import Spec
|
|
|
|
from semantic_kernel.connectors.openapi_plugin.models.rest_api_expected_response import (
|
|
RestApiExpectedResponse,
|
|
)
|
|
from semantic_kernel.connectors.openapi_plugin.models.rest_api_operation import RestApiOperation
|
|
from semantic_kernel.connectors.openapi_plugin.models.rest_api_payload import RestApiPayload
|
|
from semantic_kernel.connectors.openapi_plugin.models.rest_api_run_options import RestApiRunOptions
|
|
from semantic_kernel.connectors.openapi_plugin.server_url_validator import (
|
|
ServerUrlValidationOptions,
|
|
validate_server_url,
|
|
)
|
|
from semantic_kernel.exceptions.function_exceptions import FunctionExecutionException
|
|
from semantic_kernel.functions.kernel_arguments import KernelArguments
|
|
from semantic_kernel.utils.feature_stage_decorator import experimental
|
|
from semantic_kernel.utils.telemetry.user_agent import APP_INFO, prepend_semantic_kernel_to_user_agent
|
|
|
|
logger: logging.Logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _pin_url_to_address(url: str, address: ipaddress.IPv4Address | ipaddress.IPv6Address) -> tuple[str, str, str]:
|
|
"""Rewrite a URL so the connection targets `address` while keeping the original host identity.
|
|
|
|
Returns the address-form URL, the `Host` header value and the TLS SNI hostname, all
|
|
derived from the original URL so that the request on the wire is unchanged apart from
|
|
the address it is delivered to. IPv6 bracketing, the port and any userinfo are
|
|
preserved by `httpx.URL.copy_with`.
|
|
"""
|
|
original = httpx.URL(url)
|
|
return (
|
|
str(original.copy_with(host=str(address))),
|
|
original.netloc.decode("ascii"),
|
|
original.raw_host.decode("ascii"),
|
|
)
|
|
|
|
|
|
def _direct_mounts(
|
|
addresses: list[ipaddress.IPv4Address | ipaddress.IPv6Address],
|
|
) -> dict[str, None]:
|
|
"""Return httpx mounts that keep the pinned addresses off any environment proxy.
|
|
|
|
Pinning rewrites the request URL to the address, and httpx then matches its own
|
|
environment-proxy mounts against that address rather than against the hostname. A host
|
|
exempted by `no_proxy` would therefore be sent to the proxy in address form, undoing the
|
|
exemption. Mapping each vetted address to `None` mounts it on the default transport,
|
|
which is what this request was already determined to be: direct.
|
|
"""
|
|
return {f"all://{_bracketed(address)}": None for address in addresses}
|
|
|
|
|
|
def _bracketed(address: ipaddress.IPv4Address | ipaddress.IPv6Address) -> str:
|
|
"""Return the address as it appears in a URL authority, bracketing IPv6."""
|
|
return f"[{address}]" if address.version == 6 else str(address)
|
|
|
|
|
|
def _proxy_carries(url: str) -> bool:
|
|
"""Return whether a configured proxy would carry this specific request.
|
|
|
|
A proxy resolves the target name itself, so an address resolved locally is neither the
|
|
one used for the connection nor necessarily reachable or correct from the proxy, and
|
|
pinning has to be dropped for such a request. That is decided per URL rather than per
|
|
process: a request whose scheme has no proxy, or whose host is exempted by `no_proxy`,
|
|
is sent directly and therefore keeps the address the policy vetted.
|
|
"""
|
|
parsed = httpx.URL(url)
|
|
proxies = getproxies()
|
|
if not (proxies.get(parsed.scheme) or proxies.get("all")):
|
|
return False
|
|
host = f"[{parsed.host}]" if ":" in parsed.host else parsed.host
|
|
if parsed.port is not None:
|
|
host = f"{host}:{parsed.port}"
|
|
return not proxy_bypass(host)
|
|
|
|
|
|
@experimental
|
|
class OpenApiRunner:
|
|
"""The OpenApiRunner that runs the operations defined in the OpenAPI manifest."""
|
|
|
|
payload_argument_name = "payload"
|
|
media_type_application_json = "application/json"
|
|
|
|
def __init__(
|
|
self,
|
|
parsed_openapi_document: Mapping[str, str],
|
|
auth_callback: Callable[..., dict[str, str] | Awaitable[dict[str, str]]] | None = None,
|
|
http_client: httpx.AsyncClient | None = None,
|
|
enable_dynamic_payload: bool = True,
|
|
enable_payload_namespacing: bool = False,
|
|
server_url_validation_options: ServerUrlValidationOptions | None = None,
|
|
):
|
|
"""Initialize the OpenApiRunner."""
|
|
self.spec = Spec.from_dict(parsed_openapi_document) # type: ignore
|
|
self.auth_callback = auth_callback
|
|
self.http_client = http_client
|
|
self.enable_dynamic_payload = enable_dynamic_payload
|
|
self.enable_payload_namespacing = enable_payload_namespacing
|
|
self.server_url_validation_options = server_url_validation_options or ServerUrlValidationOptions()
|
|
|
|
def build_full_url(self, base_url, query_string):
|
|
"""Build the full URL."""
|
|
url_parts = list(urlparse(base_url))
|
|
url_parts[4] = query_string
|
|
return urlunparse(url_parts)
|
|
|
|
def build_operation_url(
|
|
self, operation: RestApiOperation, arguments: KernelArguments, server_url_override=None, api_host_url=None
|
|
):
|
|
"""Build the operation URL."""
|
|
url = operation.build_operation_url(arguments, server_url_override, api_host_url)
|
|
return self.build_full_url(url, operation.build_query_string(arguments))
|
|
|
|
def build_json_payload(self, payload_metadata: RestApiPayload, arguments: dict[str, Any]) -> tuple[str, str]:
|
|
"""Build the JSON payload."""
|
|
if self.enable_dynamic_payload:
|
|
if payload_metadata is None:
|
|
raise FunctionExecutionException(
|
|
"Payload can't be built dynamically due to the missing payload metadata."
|
|
)
|
|
|
|
payload = self.build_json_object(payload_metadata.properties, arguments)
|
|
content = json.dumps(payload)
|
|
return content, payload_metadata.media_type
|
|
|
|
argument = arguments.get(self.payload_argument_name)
|
|
if not isinstance(argument, str):
|
|
raise FunctionExecutionException(f"No payload is provided by the argument '{self.payload_argument_name}'.")
|
|
|
|
return argument, argument
|
|
|
|
def build_json_object(self, properties, arguments, property_namespace=None):
|
|
"""Build the JSON payload object."""
|
|
result = {}
|
|
|
|
for property_metadata in properties:
|
|
argument_name = self.get_argument_name_for_payload(property_metadata.name, property_namespace)
|
|
if property_metadata.type == "object":
|
|
node = self.build_json_object(property_metadata.properties, arguments, argument_name)
|
|
result[property_metadata.name] = node
|
|
continue
|
|
property_value = arguments.get(argument_name)
|
|
if property_value is not None:
|
|
result[property_metadata.name] = property_value
|
|
continue
|
|
if property_metadata.is_required:
|
|
raise FunctionExecutionException(
|
|
f"No argument is found for the '{property_metadata.name}' payload property."
|
|
)
|
|
return result
|
|
|
|
def build_operation_payload(
|
|
self, operation: RestApiOperation, arguments: KernelArguments
|
|
) -> tuple[str, str] | tuple[None, None]:
|
|
"""Build the operation payload."""
|
|
if operation.request_body is None and self.payload_argument_name not in arguments:
|
|
return None, None
|
|
|
|
if operation.request_body is not None:
|
|
return self.build_json_payload(operation.request_body, arguments)
|
|
|
|
return None, None
|
|
|
|
def get_argument_name_for_payload(self, property_name, property_namespace=None):
|
|
"""Get argument name for the payload."""
|
|
if not self.enable_payload_namespacing:
|
|
return property_name
|
|
return f"{property_namespace}.{property_name}" if property_namespace else property_name
|
|
|
|
def _get_first_response_media_type(self, responses: OrderedDict[str, RestApiExpectedResponse] | None) -> str:
|
|
if responses:
|
|
first_response = next(iter(responses.values()))
|
|
return first_response.media_type if first_response.media_type else self.media_type_application_json
|
|
return self.media_type_application_json
|
|
|
|
async def run_operation(
|
|
self,
|
|
operation: RestApiOperation,
|
|
arguments: KernelArguments | None = None,
|
|
options: RestApiRunOptions | None = None,
|
|
) -> str:
|
|
"""Runs the operation defined in the OpenAPI manifest.
|
|
|
|
When the URL is validated by DNS resolution, the request issued by the built-in
|
|
client is pinned to one of the validated addresses. Requests made through a
|
|
caller-supplied `http_client`, or through an environment proxy that carries this
|
|
URL, use that transport's own name resolution and are not pinned.
|
|
"""
|
|
if not arguments:
|
|
arguments = KernelArguments()
|
|
url = self.build_operation_url(
|
|
operation=operation,
|
|
arguments=arguments,
|
|
server_url_override=options.server_url_override if options else None,
|
|
api_host_url=options.api_host_url if options else None,
|
|
)
|
|
validated_addresses = await validate_server_url(url, self.server_url_validation_options)
|
|
headers = operation.build_headers(arguments=arguments)
|
|
payload, _ = self.build_operation_payload(operation=operation, arguments=arguments)
|
|
|
|
if self.auth_callback:
|
|
headers_update = self.auth_callback(**headers)
|
|
if isawaitable(headers_update):
|
|
headers_update = await headers_update
|
|
# at this point, headers_update is a valid dictionary
|
|
headers.update(headers_update) # type: ignore
|
|
|
|
if APP_INFO:
|
|
headers.update(APP_INFO)
|
|
headers = prepend_semantic_kernel_to_user_agent(headers)
|
|
|
|
if "Content-Type" not in headers:
|
|
responses = (
|
|
operation.responses
|
|
if isinstance(operation.responses, OrderedDict)
|
|
else OrderedDict(operation.responses or {})
|
|
)
|
|
headers["Content-Type"] = self._get_first_response_media_type(responses)
|
|
|
|
timeout = options.timeout if options and hasattr(options, "timeout") and options.timeout is not None else None
|
|
|
|
# Pin the connection to an address the validator actually vetted so that a name which
|
|
# resolves to a public address during validation cannot resolve to a private one at
|
|
# connect time (DNS rebinding). The list is empty when there is nothing to pin.
|
|
pinned_addresses = validated_addresses
|
|
if pinned_addresses and _proxy_carries(url):
|
|
logger.debug("An environment proxy carries this URL; the OpenAPI request address is not pinned.")
|
|
pinned_addresses = []
|
|
|
|
async def fetch():
|
|
async def make_request(
|
|
client: httpx.AsyncClient,
|
|
pin_to: ipaddress.IPv4Address | ipaddress.IPv6Address | None = None,
|
|
):
|
|
merged_headers = client.headers.copy()
|
|
merged_headers.update(headers)
|
|
request_url = url
|
|
extensions: dict[str, Any] = {}
|
|
if pin_to is not None:
|
|
request_url, merged_headers["Host"], extensions["sni_hostname"] = _pin_url_to_address(url, pin_to)
|
|
response = await client.request(
|
|
method=operation.method,
|
|
url=request_url,
|
|
headers=merged_headers,
|
|
json=json.loads(payload) if payload else None,
|
|
extensions=extensions,
|
|
)
|
|
response.raise_for_status()
|
|
return response.text
|
|
|
|
if hasattr(self, "http_client") and self.http_client is not None:
|
|
# A caller-supplied client owns its transport configuration (proxies, mounts,
|
|
# custom resolvers), so its connections are left untouched.
|
|
return await make_request(self.http_client)
|
|
async with httpx.AsyncClient(timeout=timeout, mounts=_direct_mounts(pinned_addresses)) as client:
|
|
if not pinned_addresses:
|
|
return await make_request(client)
|
|
# Every vetted address is an acceptable target, so keep the resolver's
|
|
# fallback behaviour by trying the next one when a connection cannot be
|
|
# established. Only connection failures are retried, so no request is
|
|
# ever delivered more than once.
|
|
*fallback_addresses, final_address = pinned_addresses
|
|
for address in fallback_addresses:
|
|
try:
|
|
return await make_request(client, pin_to=address)
|
|
except (httpx.ConnectError, httpx.ConnectTimeout):
|
|
logger.debug("Could not connect to validated address %s, trying the next one.", address)
|
|
return await make_request(client, pin_to=final_address)
|
|
|
|
return await fetch()
|