Skip to content

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

Description

Summary

validate_server_url (connectors/openapi_plugin/server_url_validator.py) protects the
OpenAPI plugin against SSRF by resolving the target host and blocking
private/loopback/link-local/metadata addresses. However, the validated IP is not
reused for the actual request: openapi_runner.run_operation calls
validate_server_url(url, ...) (openapi_runner.py ~L146) with no dns_resolver, then
issues the request via httpx.AsyncClient(...).request(url=<hostname>) (~L172-186),
which re-resolves the hostname independently at connect time. A host that resolves
to a public address during validation and to a private address at connect time
(classic DNS rebinding) passes the check and is then contacted. Because
run_operation also attaches auth_callback credentials to the request, the request
that reaches the rebound address is credential-bearing.

Severity (stated honestly — this is hardening, not a high-severity SSRF)

Impact in the default configuration is low, because other layers already constrain it:

  • The validator forces https by default, and httpx verifies TLS certificates
    (verify=True), so a rebind to e.g. 169.254.169.254 fails the TLS handshake — the
    request is not sent and no credential is disclosed over the default https path. The
    residual over https is a blind connection attempt (TCP connect + ClientHello) to the
    internal IP, not data/credential exfiltration.
  • Full SSRF + credential disclosure via rebinding requires an operator-configured
    http allowed_base_urls entry, or a caller-supplied http_client with
    verify=False, or a host platform that ingests untrusted OpenAPI specs/overrides.
  • The feature is @experimental.

I'm filing this as defense-in-depth: the validator is a deliberate anti-SSRF control,
and pinning the resolved IP closes the one check-time/use-time gap in it.

Reproduction (mechanism; offline)

semantic-kernel 1.44.1. Making getaddrinfo return a public IP on the 1st lookup
(validation) and a link-local IP on the 2nd (connect) shows the validator passes while
the connection target is an address it would have blocked:

import asyncio, socket
from semantic_kernel.connectors.openapi_plugin.server_url_validator import (
    validate_server_url, try_categorize_non_public_address)

HOST, PUBLIC, META = "rebind.example", "93.184.216.34", "169.254.169.254"
_real, n = socket.getaddrinfo, {"i": 0}
def rebinding(host, *a, **k):
    if host == HOST:
        n["i"] += 1
        ip = PUBLIC if n["i"] == 1 else META
        return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", (ip, 0))]
    return _real(host, *a, **k)
socket.getaddrinfo = rebinding

async def main():
    await validate_server_url(f"https://{HOST}/api/op")     # 1st resolution -> public -> PASSES
    connect_ip = socket.getaddrinfo(HOST, 443)[0][4][0]     # 2nd -> 169.254.169.254 (what httpx uses)
    print("validated public; connect IP:", connect_ip, try_categorize_non_public_address(connect_ip))
asyncio.run(main())

I did not stand up a live authoritative rebinding DNS server + real httpx connection;
this demonstrates the resolve-then-connect gap the runner relies on.

Suggested remediation

Resolve once and pin: connect to the validated IP (e.g. a custom httpx transport /
resolver that reuses the vetted address while preserving SNI/Host), or re-validate the
peer IP at connect time. Consider applying the IP check on the allowed_base_urls path
too (it currently matches on hostname strings without resolving), and re-validating
after redirects if a caller-supplied client enables follow_redirects.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions