Skip to content

fix: dial every pinned address so a single-family peer is reachable - #7409

Open
ScarAnd wants to merge 1 commit into
IBM:mainfrom
ScarAnd:fix/ssrf-pin-multi-address
Open

ScarAnd wants to merge 1 commit into
IBM:mainfrom
ScarAnd:fix/ssrf-pin-multi-address

Conversation

@ScarAnd

@ScarAnd ScarAnd commented Nov 2, 2026 •

Copy link
Copy Markdown

Problem

PinnedTarget.pin() rewrites a URL to resolved_ips[0] and never tries the remaining validated addresses. #6965 replaced httpx's own multi-address connect with that single dial, so an outbound request fails whenever the peer listens on only one of the address families the hostname resolves to.

localhost is the easy reproduction: it resolves to ::1 first on macOS and most Debian/Ubuntu hosts, while a provider bound to 127.0.0.1 refuses the ::1 attempt and the request never gets a second chance. SSRF_ALLOW_LOCALHOST=true grants the destination, the URL policy passes, and the pin then picks the address family the peer is not listening on.

Every pin() caller had the behaviour:

Caller Path
oauth_manager._post_token_request OAuth token exchange
llm_proxy_service (×2) non-streaming and streaming completions
a2a_service cross-gateway calls
gateway_service._check_single_gateway_health SSE health check

SniPinningTransport already loops over pinned_hosts and falls back on connect errors, so the module disagreed with itself.

Fixes #7407.

Change

  • PinnedTarget.connect_async() and PinnedTarget.stream_async() try each validated address in turn and retry on httpx.ConnectError / httpx.ConnectTimeout.
  • Both take the caller's existing client, so purpose-built clients keep their custom CA SSLContext, per-call timeouts, and follow_redirects=False.
  • Both re-apply apply_headers(), so every attempt carries the validated authority in Host, never the dialled address.
  • The five call sites move onto the two helpers.
  • pin() is marked deprecated, since it dials a single address. It has no callers left in mcpgateway/.
  • The helpers dispatch through client.post / client.get, so caller stubs and instrumentation keep working.

Addresses are not reordered. The policy already checked every one of them, and every one remains a dial target.

Verification

Reproduced against a purpose-built IPv6-only peer (::1:8077, 127.0.0.1 refused, localhost resolving to both):

resolved       : ('::1', '127.0.0.1')
connect_async  : SUCCESS 200 {"ok":true,"host":"localhost:8077"}
stream_async   : SUCCESS 200 {"ok":true,"host":"localhost:8077"}

The peer answered on IPv6 after the IPv4 attempt was refused, with Host: localhost:8077 intact.

Live gateway check: authorization-code flow through a running gateway returns callback 200 and stores the token.

Tests:

  • tests/security/test_outbound_dns_pinning.py — new cases for IPv4→IPv6 failover, IPv6→IPv4 failover, all-addresses-refused, Host preserved on every attempt, and pass-through when unpinned.
  • Three existing assertions in that file recorded the SNI value in request.extensions, which the helpers no longer set. They now assert the dial and Host contract, and state explicitly that no sni_hostname extension is sent.
  • tests/unit/mcpgateway/services/test_oauth_manager_pkce.py, test_a2a_service.py, test_llm_proxy_service.py, test_oauth_manager.py, and tests/unit/mcpgateway/test_oauth_manager.py pass unchanged.

ruff check and ruff format clean on the changed files.

PinnedTarget.pin() rewrote the URL to resolved_ips[0] and never tried the
remaining validated addresses. IBM#6965 replaced httpx's own multi-address
connect with that single dial, so an outbound request fails whenever the
peer listens on only one of the address families the hostname resolves to.

localhost is the easy reproduction: it resolves to ::1 first on macOS and
most Debian/Ubuntu hosts, while a provider that binds 127.0.0.1 refuses the
::1 attempt and the request never gets a second chance.

Every pin() caller had the behaviour: OAuth token exchange, the LLM proxy
(non-streaming and streaming), A2A cross-gateway calls, and the SSE health
check in gateway_service. SniPinningTransport already loops over pinned
addresses, so the module disagreed with itself.

Add connect_async() and stream_async() to PinnedTarget. They use the
caller's existing httpx client, try each validated address in turn, and
retry on ConnectError and ConnectTimeout. Both keep the validated authority
in the Host header through apply_headers(). Move the five call sites onto
them and mark pin() deprecated, since it dials a single address.

The helpers dispatch to client.post / client.get so caller stubs and
instrumentation keep working.

Verified against a purpose-built IPv6-only peer (::1:8077, 127.0.0.1
refused): both helpers fail over from IPv4 to IPv6 and return 200 with
Host: localhost:8077.

Closes IBM#7407

Signed-off-by: Andrin Enz <Andrin.Enz@ibm.com>
ScarAnd pushed a commit to ScarAnd/mcp-context-forge that referenced this pull request Nov 2, 2026
Local port of upstream PR IBM#7409 (IBM/mcp-context-forge), which fixes
IBM#7407.

PinnedTarget.pin() rewrote the URL to resolved_ips[0] and never tried the
remaining validated addresses. IBM#6965 replaced httpx's own multi-address
connect with that single dial, so an outbound request fails whenever the
peer listens on only one of the address families the hostname resolves to.

localhost is the easy reproduction: it resolves to ::1 first on macOS and
most Debian/Ubuntu hosts, while a provider bound to 127.0.0.1 refuses the
::1 attempt and the request never gets a second chance.

Every pin() caller had the behaviour: OAuth token exchange, the LLM proxy
(non-streaming and streaming), A2A cross-gateway calls, and the SSE health
check in gateway_service.

Add connect_async() and stream_async() to PinnedTarget. They use the
caller's existing httpx client, try each validated address in turn, and
retry on ConnectError and ConnectTimeout. Both keep the validated authority
in the Host header through apply_headers(). Move the five call sites onto
them and mark pin() deprecated, since it dials a single address.

Applied by hand around the local Cohere streaming branches in
llm_proxy_service.py rather than merged, so this fork's ollama and cohere
work stays intact.

Verified against a purpose-built IPv6-only peer (::1:8077, 127.0.0.1
refused): both helpers fail over from IPv4 to IPv6 and return 200 with
Host: localhost:8077.

Closes IBM#7407

Signed-off-by: Andrin Enz <Andrin.Enz@ibm.com>
@madhu-mohan-jaishankar madhu-mohan-jaishankar self-assigned this Nov 2, 2026

This branch has not been deployed

No deployments
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.

[BUG]: SSRF pinning dials only resolved_ips[0], so an outbound request fails when the peer listens on one address family

2 participants