Uh oh!
There was an error while loading. Please reload this page.
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 |
Uh oh!
There was an error while loading. Please reload this page.
waleedlatif1
commented
Jul 21, 2026
waleedlatif1
commented
Jul 21, 2026
@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.
Uh oh!
There was an error while loading. Please reload this page.
…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