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.
Summary
validate_server_url(connectors/openapi_plugin/server_url_validator.py) protects theOpenAPI 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_operationcallsvalidate_server_url(url, ...)(openapi_runner.py ~L146) with nodns_resolver, thenissues 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_operationalso attachesauth_callbackcredentials to the request, the requestthat 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:
httpsby default, andhttpxverifies TLS certificates(
verify=True), so a rebind to e.g.169.254.169.254fails the TLS handshake — therequest 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.
httpallowed_base_urlsentry, or a caller-suppliedhttp_clientwithverify=False, or a host platform that ingests untrusted OpenAPI specs/overrides.@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
getaddrinforeturn 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:
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_urlspathtoo (it currently matches on hostname strings without resolving), and re-validating
after redirects if a caller-supplied client enables
follow_redirects.