fix(mcp): pin outbound connections to IPv4 to avoid unreachable-IPv6 hangs#5798
Conversation
…hangs Root cause of the MCP 'connecting forever' / tool-discovery timeouts in production: SSRF pinning forces each outbound connection to a single resolved IP, which strips Happy Eyeballs' IPv4 fallback. dns.lookup(verbatim) returns the IPv6 address first for Cloudflare-fronted dual-stack hosts (Gauge, api.exa.ai), so the connection was pinned to IPv6 — but the production app subnets have no IPv6 egress (AWS NAT gateways are IPv4-only), so the pinned IPv6 connection connects into a void and hangs until the 30s timeout. Intermittent because the resolver rotates A/AAAA order; works on retry when it happens to pick IPv4. This is why h1.1 (#5797) did not fix it (protocol-independent) and why it never reproduced locally (dev machines have IPv6 egress). Empirically verified: pinning only the IPv6 address times out; preferring the IPv4 address connects in ~600ms. Fix: at both pinned-resolution sites (validateMcpServerSsrf and validateUrlWithDNS) resolve all addresses and prefer an IPv4 one; IPv6-only hosts still pin their sole address. No signature/threading changes; SSRF validation of the pinned IP is unchanged.
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
PR SummaryMedium Risk Overview
Tests mock the multi-record lookup shape and add cases for dual-stack (IPv4 preferred) and IPv6-only pinning. Reviewed by Cursor Bugbot for commit a866409. Configure here. |
Greptile SummaryThis PR changes pinned outbound connections to prefer IPv4 while preserving IPv6-only resolution. The main changes are:
Confidence Score: 5/5This looks safe to merge.
Important Files Changed
Reviews (2): Last reviewed commit: "chore(mcp): trim inline comments on the ..." | Re-trigger Greptile |
|
@cursor review |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit a866409. Configure here.
…5799) Same class of bug fixed for MCP in #5798: SSRF pinning forces a single resolved IP, which strips Happy Eyeballs' IPv4 fallback. dns.lookup(verbatim) returns the IPv6 address first for dual-stack hosts, so a database host or 1Password Connect server that is dual-stack gets pinned to IPv6 — unreachable on IPv4-only egress (AWS NAT gateways) and hangs until timeout. validateDatabaseHost and validateConnectServerUrl now resolve all addresses and prefer IPv4; IPv6-only hosts still pin their sole address. SSRF validation of the pinned IP is unchanged (the selected address is the one validated and pinned).
…lower - Restore validateUrlWithDNS's prefer-IPv4 resolution (a stale working-tree hunk accidentally reverted #5798 for the still-pinned non-MCP consumers). - Validate the INITIAL url's IP-literal in followRedirectsGuarded, not just redirect hops, so the exported guard is self-contained. - Drop entity headers (content-length/type/encoding) when a 301/302/303 switches a POST to a bodyless GET, which undici would otherwise reject. - Lift method/headers/body/signal from a Request input instead of silently downgrading a guarded POST Request to a bare GET.
…rd (#5823) * fix(mcp): replace single-IP pinning with validate-at-connect SSRF guard Swaps the MCP transport + OAuth guard from pin-one-resolved-IP to the standard pattern (LibreChat getSSRFConnect): DNS resolves normally and EVERY socket connect filters the resolved addresses against the private/reserved blocklist, closing the validate-then-trust window a rebind could race and removing the non-standard mechanism implicated in the headers-then-no-body stalls (no reference MCP client pins IPs; a pinned attempt welded to a bad flow cannot escape, while retries rotate via resolver round-robin and succeed). Adversarially reviewed before shipping; both findings closed here: - Redirects are now followed manually with per-hop validation: an IP-literal redirect target (which bypasses ANY connect-time lookup - Node skips the custom lookup for numeric hosts) is checked explicitly, closing a metadata- endpoint redirect hole the old pin also had. - Custom request headers are dropped on cross-origin hops, so a redirect to a second attacker host cannot harvest configured auth headers. Policy gating unchanged: the guard activates exactly where the pin did (validateMcpServerSsrf non-null; allowlist mode / localhost-on-self-hosted stay unguarded). Non-MCP consumers of the pinned fetch are untouched. 397 tests incl. new rebinding, mixed-answer, IP-literal-redirect, and hop-cap coverage. * fix(mcp): restore IPv4 preference and harden the guarded redirect follower - Restore validateUrlWithDNS's prefer-IPv4 resolution (a stale working-tree hunk accidentally reverted #5798 for the still-pinned non-MCP consumers). - Validate the INITIAL url's IP-literal in followRedirectsGuarded, not just redirect hops, so the exported guard is self-contained. - Drop entity headers (content-length/type/encoding) when a 301/302/303 switches a POST to a bodyless GET, which undici would otherwise reject. - Lift method/headers/body/signal from a Request input instead of silently downgrading a guarded POST Request to a bare GET. * fix(mcp): annotate headers double-cast and cancel redirect body before throw paths - Add the missing double-cast-allowed annotation on the sanitized-headers cast (strict boundary audit). - Cancel the redirect response body before the hop-cap / blocked-target throws so those paths can't leave a socket checked out on the long-lived Agent. * fix(mcp): keep self-hosted private-resolving hosts unguarded (old-pin parity) A DNS alias resolving to loopback/private is only reachable on self-hosted, where the policy explicitly permits it; the guarded lookup would filter the address and strand the connect where the old pin connected. Both MCP gates (transport + OAuth guard) now route private/loopback resolutions over the unguarded path, same as the localhost carve-out. Test IPs moved off RFC-5737 TEST-NET (which is correctly classified reserved). * fix(mcp): pin (not skip) self-hosted private resolutions; refuse cross-origin body forwards - The private/loopback carve-out now keeps the LEGACY PIN to the validated address instead of falling back to unguarded fetch — preserving both the old behavior and its anti-rebinding property for self-hosted DNS aliases. - Cross-origin redirect hops now also refuse to forward a request body (307/308 preserve method+body; post-pin those redirects really dial the new origin, so an open redirect could exfiltrate OAuth client secrets). Bodyless cross-origin redirects still follow. Tests for both. * chore(mcp): true up stale pinned-era doc comments
The real root cause (empirically confirmed)
The MCP "connecting forever" / tool-discovery 30s timeouts in production — affecting Gauge, Exa, and PlanetScale alike — were caused by IP-family pinning, not OAuth or HTTP/2 (which the earlier PRs fixed but were different layers).
The chain, every link verified against production:
createPinnedLookup), which strips Happy Eyeballs' IPv4 fallback.dns.lookup(host, { verbatim: true })returns the IPv6 address first for Cloudflare-fronted dual-stack hosts — verified:app.withgauge.com→2606:4700:…,api.exa.ai→2606:4700:….AbortError,durationMs=30002). Intermittent because the resolver rotates A/AAAA order (works on retry when it picks IPv4).This explains everything that didn't add up: why #5797 (h1.1) didn't fix it (protocol-independent), and why it never reproduced locally (dev machines have IPv6 egress).
Empirical proof (undici, pinned lookup):
Fix
At both pinned-resolution sites —
validateMcpServerSsrf(MCP) andvalidateUrlWithDNS(providers / A2A / SMTP, same latent bug) — resolve all addresses ({ all: true, verbatim: true }) and prefer an IPv4 address; IPv6-only hosts still pin their sole address. This keeps the single-IP pinning API and threading untouched (near-zero blast radius) and SSRF validation of the pinned IP is unchanged.Verified end-to-end:
app.withgauge.comandapi.exa.ainow pin their IPv4 address instead of the unreachable IPv6.Type of Change
Testing
lib/mcpsuite green (374);lib/core/securitygreen; tsc + biome clean.Note
A fuller fix (pin the validated set of all resolved IPs and let undici do Happy Eyeballs, so it's egress-agnostic) is a good follow-up, but it threads
string → string[]through ~10 security-critical sites; preferring IPv4 fixes the confirmed bug now with minimal risk.Checklist