Skip to content

fix: pin validated DNS addresses for OpenAPI requests - #14317

Open
mikemikimike wants to merge 1 commit into
microsoft:mainfrom
mikemikimike:fix/openapi-dns-rebinding
Open

fix: pin validated DNS addresses for OpenAPI requests#14317
mikemikimike wants to merge 1 commit into
microsoft:mainfrom
mikemikimike:fix/openapi-dns-rebinding

Conversation

@mikemikimike

Copy link
Copy Markdown

Fixes #14312

Background

OpenApiRunner validates a hostname with DNS before making an HTTP request, but the default httpx transport resolves the hostname again when opening the connection. DNS rebinding can therefore cause the connection to target a different, private address than the one that passed SSRF validation.

Changes

  • Return the validated public address from validate_server_url.
  • Add PinnedDnsTransport, which connects validated hostnames to the resolved address while preserving the original request hostname for HTTP Host handling and TLS SNI.
  • Use the transport for default OpenAPI HTTP requests.
  • Add a regression assertion for the validated-address contract.

Requests using an explicitly supplied http_client remain under that client's transport configuration. Explicitly allowed base URLs and private-network opt-in retain their existing behavior and do not use pinning.

Tests

  • uv run --project python --python 3.12 pytest python/tests/unit/connectors/openapi_plugin/test_server_url_validator.py python/tests/unit/connectors/openapi_plugin/test_openapi_runner.py -q — 73 passed.
  • uv run --project python ruff check python/semantic_kernel/connectors/openapi_plugin python/tests/unit/connectors/openapi_plugin — passed.
  • git diff --check — passed.

The full test suite was not run because it is outside the scope of this change.

Copilot AI lite review requested due to automatic review settings August 23, 2026 11:07
@mikemikimike
mikemikimike requested a review from a team as a code owner August 23, 2026 11:07

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR closes a DNS check-time vs use-time gap in the Python OpenAPI plugin by returning the validated DNS result from validate_server_url and using a custom HTTP transport to connect to the validated IP (while preserving the original hostname for Host/SNI handling).

Changes:

  • Change validate_server_url to return the validated host→IP mapping needed for connection pinning.
  • Add PinnedDnsTransport (and backend) to connect validated hostnames to the validated IP address.
  • Update OpenApiRunner to use the pinned transport for default requests and add a regression assertion for the new return contract.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 5 comments.

File Description
python/tests/unit/connectors/openapi_plugin/test_server_url_validator.py Updates tests to assert the new {host: ip} return contract.
python/semantic_kernel/connectors/openapi_plugin/server_url_validator.py Returns validated host→IP mapping (or {} when pinning is not used) instead of None.
python/semantic_kernel/connectors/openapi_plugin/pinned_http_transport.py Introduces a custom httpx transport that pins the TCP connection to a validated address.
python/semantic_kernel/connectors/openapi_plugin/openapi_runner.py Uses the pinned transport for default OpenAPI HTTP requests when validation returns a pinned host.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +25 to +37
ssl_context = httpx.create_ssl_context(
verify=kwargs.pop("verify", True), cert=kwargs.pop("cert", None), trust_env=kwargs.pop("trust_env", True)
)
limits = kwargs.pop("limits", httpx.Limits())
self._pool = httpcore.AsyncConnectionPool(
ssl_context=ssl_context,
max_connections=limits.max_connections,
max_keepalive_connections=limits.max_keepalive_connections,
keepalive_expiry=limits.keepalive_expiry,
http1=kwargs.pop("http1", True),
http2=kwargs.pop("http2", False),
network_backend=_PinnedNetworkBackend(pinned_hosts),
)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged. The pinned transport currently applies only to the default direct-client path and does not preserve environment proxy selection. I will handle proxy-aware transport selection in a follow-up revision; the explicit http_client path remains unchanged.

Comment on lines +54 to +58
return httpx.Response(
status_code=response.status,
headers=response.headers,
stream=httpx._transports.default.AsyncResponseStream(response.stream),
extensions=response.extensions,

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c5c8db7 by replacing the private httpx response-stream adapter with a public httpx.AsyncByteStream implementation.

Comment on lines +23 to +37
def __init__(self, pinned_hosts: Mapping[str, str], **kwargs):
"""Initialize a transport using fixed addresses for validated hosts."""
ssl_context = httpx.create_ssl_context(
verify=kwargs.pop("verify", True), cert=kwargs.pop("cert", None), trust_env=kwargs.pop("trust_env", True)
)
limits = kwargs.pop("limits", httpx.Limits())
self._pool = httpcore.AsyncConnectionPool(
ssl_context=ssl_context,
max_connections=limits.max_connections,
max_keepalive_connections=limits.max_keepalive_connections,
keepalive_expiry=limits.keepalive_expiry,
http1=kwargs.pop("http1", True),
http2=kwargs.pop("http2", False),
network_backend=_PinnedNetworkBackend(pinned_hosts),
)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c5c8db7: unsupported transport options now fail fast with TypeError instead of being silently ignored.

Comment on lines 32 to 38
async def validate_server_url(
url: str,
options: ServerUrlValidationOptions | None = None,
dns_resolver: DnsResolver | None = None,
) -> None:
) -> dict[str, str]:
"""Validate a fully resolved OpenAPI operation URL against the supplied policy."""
options = options or ServerUrlValidationOptions()

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c5c8db7: the validator docstring now documents the host-to-address return contract and the empty-mapping bypass cases.

Comment on lines 148 to +151

for address in addresses:
_ensure_public_address(parsed_url.geturl(), address)
return {host: str(addresses[0])}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed. The current mapping pins the first validated address, so multi-address DNS results can lose the connector's normal fallback behavior. I will address this by retaining the validated address set and trying the remaining validated public addresses on connection failure.

@mikemikimike
mikemikimike force-pushed the fix/openapi-dns-rebinding branch from e26bf5b to c5c8db7 Compare August 23, 2026 11:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

OpenAPI plugin SSRF validator: resolved IP is not pinned for the connection (DNS check-time vs use-time gap)

2 participants