From b249ea0c0451d6d79109f79d939a532917587e02 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 20 Jul 2026 22:08:00 -0700 Subject: [PATCH 1/2] fix(mcp): pin outbound connections to IPv4 to avoid unreachable-IPv6 hangs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../core/security/input-validation.server.ts | 7 +++- apps/sim/lib/mcp/domain-check.test.ts | 37 ++++++++++++++----- apps/sim/lib/mcp/domain-check.ts | 9 ++++- 3 files changed, 41 insertions(+), 12 deletions(-) diff --git a/apps/sim/lib/core/security/input-validation.server.ts b/apps/sim/lib/core/security/input-validation.server.ts index cfef31a08d2..3ab0ee4ac9b 100644 --- a/apps/sim/lib/core/security/input-validation.server.ts +++ b/apps/sim/lib/core/security/input-validation.server.ts @@ -105,7 +105,12 @@ export async function validateUrlWithDNS( } try { - const { address } = await dns.lookup(cleanHostname, { verbatim: true }) + // Prefer IPv4: the resolved address is pinned by the caller, which removes Happy + // Eyeballs' IPv4 fallback. On an IPv4-only egress (e.g. AWS NAT gateways) a pinned + // IPv6 address from a dual-stack host is unreachable and hangs until timeout, so pick + // an IPv4 address when the host has one; IPv6-only hosts still pin their sole address. + const resolved = await dns.lookup(cleanHostname, { all: true, verbatim: true }) + const { address } = resolved.find((entry) => entry.family === 4) ?? resolved[0] const resolvedIsLoopback = ipaddr.isValid(address) && diff --git a/apps/sim/lib/mcp/domain-check.test.ts b/apps/sim/lib/mcp/domain-check.test.ts index 28f7bab6008..ab0616c6ac2 100644 --- a/apps/sim/lib/mcp/domain-check.test.ts +++ b/apps/sim/lib/mcp/domain-check.test.ts @@ -364,12 +364,31 @@ describe('validateMcpServerSsrf', () => { }) it('returns resolved IP for URLs that resolve to public IPs', async () => { - mockDnsLookup.mockResolvedValue({ address: '93.184.216.34' }) + mockDnsLookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }]) await expect(validateMcpServerSsrf('https://example.com/mcp')).resolves.toBe('93.184.216.34') }) + it('prefers IPv4 over IPv6 for a dual-stack host (verbatim returns IPv6 first)', async () => { + // Cloudflare-fronted hosts resolve IPv6-first; pinning that IPv6 hangs on an IPv4-only + // egress. The guard must pin the reachable IPv4 instead. + mockDnsLookup.mockResolvedValue([ + { address: '2606:4700:3037::ac43:cc5f', family: 6 }, + { address: '104.21.22.105', family: 4 }, + ]) + await expect(validateMcpServerSsrf('https://app.withgauge.com/mcp')).resolves.toBe( + '104.21.22.105' + ) + }) + + it('pins the sole IPv6 address for an IPv6-only host', async () => { + mockDnsLookup.mockResolvedValue([{ address: '2606:4700:3037::ac43:cc5f', family: 6 }]) + await expect(validateMcpServerSsrf('https://ipv6-only.example/mcp')).resolves.toBe( + '2606:4700:3037::ac43:cc5f' + ) + }) + it('returns resolved IP for HTTP URLs on non-localhost hosts', async () => { - mockDnsLookup.mockResolvedValue({ address: '93.184.216.34' }) + mockDnsLookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }]) await expect(validateMcpServerSsrf('http://example.com:3000/mcp')).resolves.toBe( '93.184.216.34' ) @@ -405,12 +424,12 @@ describe('validateMcpServerSsrf', () => { }) it('throws McpSsrfError for URLs resolving to private IPs', async () => { - mockDnsLookup.mockResolvedValue({ address: '10.0.0.5' }) + mockDnsLookup.mockResolvedValue([{ address: '10.0.0.5', family: 4 }]) await expect(validateMcpServerSsrf('https://internal.corp/mcp')).rejects.toThrow(McpSsrfError) }) it('throws McpSsrfError for URLs resolving to link-local IPs', async () => { - mockDnsLookup.mockResolvedValue({ address: '169.254.169.254' }) + mockDnsLookup.mockResolvedValue([{ address: '169.254.169.254', family: 4 }]) await expect(validateMcpServerSsrf('https://metadata.internal/latest')).rejects.toThrow( McpSsrfError ) @@ -424,7 +443,7 @@ describe('validateMcpServerSsrf', () => { }) it('returns resolved IP for URLs resolving to loopback on self-hosted (localhost alias)', async () => { - mockDnsLookup.mockResolvedValue({ address: '127.0.0.1' }) + mockDnsLookup.mockResolvedValue([{ address: '127.0.0.1', family: 4 }]) await expect(validateMcpServerSsrf('http://my-local-alias:3000/mcp')).resolves.toBe('127.0.0.1') }) @@ -450,14 +469,14 @@ describe('validateMcpServerSsrf', () => { }) it('rejects URLs resolving to loopback on hosted', async () => { - mockDnsLookup.mockResolvedValue({ address: '127.0.0.1' }) + mockDnsLookup.mockResolvedValue([{ address: '127.0.0.1', family: 4 }]) await expect(validateMcpServerSsrf('http://my-local-alias:3000/mcp')).rejects.toThrow( McpSsrfError ) }) it('returns resolved IP for public IP resolutions on hosted', async () => { - mockDnsLookup.mockResolvedValue({ address: '93.184.216.34' }) + mockDnsLookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }]) await expect(validateMcpServerSsrf('https://example.com/mcp')).resolves.toBe('93.184.216.34') }) @@ -483,7 +502,7 @@ describe('validateMcpServerSsrf', () => { }) it('still blocks DNS resolutions to private IPs on hosted (regression)', async () => { - mockDnsLookup.mockResolvedValue({ address: '10.0.0.5' }) + mockDnsLookup.mockResolvedValue([{ address: '10.0.0.5', family: 4 }]) await expect(validateMcpServerSsrf('https://internal.corp/mcp')).rejects.toThrow(McpSsrfError) }) @@ -508,7 +527,7 @@ describe('validateMcpServerSsrf', () => { }) it('returns resolved loopback IP for DNS aliases (caller pins)', async () => { - mockDnsLookup.mockResolvedValue({ address: '127.0.0.1' }) + mockDnsLookup.mockResolvedValue([{ address: '127.0.0.1', family: 4 }]) await expect(validateMcpServerSsrf('http://my-local-alias/mcp')).resolves.toBe('127.0.0.1') }) }) diff --git a/apps/sim/lib/mcp/domain-check.ts b/apps/sim/lib/mcp/domain-check.ts index d48588ead6b..36196eb0999 100644 --- a/apps/sim/lib/mcp/domain-check.ts +++ b/apps/sim/lib/mcp/domain-check.ts @@ -185,8 +185,13 @@ export async function validateMcpServerSsrf(url: string | undefined): Promise entry.family === 4) ?? resolved[0]).address } catch (error) { logger.warn('DNS lookup failed for MCP server URL', { hostname, From a8664099cf6480cf279334c004695d2f9bee699d Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 20 Jul 2026 22:13:40 -0700 Subject: [PATCH 2/2] chore(mcp): trim inline comments on the IPv4-preference fix --- apps/sim/lib/core/security/input-validation.server.ts | 6 ++---- apps/sim/lib/mcp/domain-check.ts | 7 ++----- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/apps/sim/lib/core/security/input-validation.server.ts b/apps/sim/lib/core/security/input-validation.server.ts index 3ab0ee4ac9b..5a89efc18ee 100644 --- a/apps/sim/lib/core/security/input-validation.server.ts +++ b/apps/sim/lib/core/security/input-validation.server.ts @@ -105,10 +105,8 @@ export async function validateUrlWithDNS( } try { - // Prefer IPv4: the resolved address is pinned by the caller, which removes Happy - // Eyeballs' IPv4 fallback. On an IPv4-only egress (e.g. AWS NAT gateways) a pinned - // IPv6 address from a dual-stack host is unreachable and hangs until timeout, so pick - // an IPv4 address when the host has one; IPv6-only hosts still pin their sole address. + // Prefer IPv4: pinning strips Happy Eyeballs' fallback, and a pinned IPv6 address hangs + // on IPv4-only egress (e.g. AWS NAT gateways). See validateMcpServerSsrf. const resolved = await dns.lookup(cleanHostname, { all: true, verbatim: true }) const { address } = resolved.find((entry) => entry.family === 4) ?? resolved[0] diff --git a/apps/sim/lib/mcp/domain-check.ts b/apps/sim/lib/mcp/domain-check.ts index 36196eb0999..e04b6f04cc3 100644 --- a/apps/sim/lib/mcp/domain-check.ts +++ b/apps/sim/lib/mcp/domain-check.ts @@ -185,11 +185,8 @@ export async function validateMcpServerSsrf(url: string | undefined): Promise entry.family === 4) ?? resolved[0]).address } catch (error) {