Skip to content

Commit d381eef

Browse files
authored
fix(mcp): pin outbound connections to IPv4 to avoid unreachable-IPv6 hangs (#5798)
* fix(mcp): pin outbound connections to IPv4 to avoid unreachable-IPv6 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. * chore(mcp): trim inline comments on the IPv4-preference fix
1 parent 48103e9 commit d381eef

3 files changed

Lines changed: 36 additions & 12 deletions

File tree

apps/sim/lib/core/security/input-validation.server.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,10 @@ export async function validateUrlWithDNS(
105105
}
106106

107107
try {
108-
const { address } = await dns.lookup(cleanHostname, { verbatim: true })
108+
// Prefer IPv4: pinning strips Happy Eyeballs' fallback, and a pinned IPv6 address hangs
109+
// on IPv4-only egress (e.g. AWS NAT gateways). See validateMcpServerSsrf.
110+
const resolved = await dns.lookup(cleanHostname, { all: true, verbatim: true })
111+
const { address } = resolved.find((entry) => entry.family === 4) ?? resolved[0]
109112

110113
const resolvedIsLoopback =
111114
ipaddr.isValid(address) &&

apps/sim/lib/mcp/domain-check.test.ts

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -364,12 +364,31 @@ describe('validateMcpServerSsrf', () => {
364364
})
365365

366366
it('returns resolved IP for URLs that resolve to public IPs', async () => {
367-
mockDnsLookup.mockResolvedValue({ address: '93.184.216.34' })
367+
mockDnsLookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }])
368368
await expect(validateMcpServerSsrf('https://example.com/mcp')).resolves.toBe('93.184.216.34')
369369
})
370370

371+
it('prefers IPv4 over IPv6 for a dual-stack host (verbatim returns IPv6 first)', async () => {
372+
// Cloudflare-fronted hosts resolve IPv6-first; pinning that IPv6 hangs on an IPv4-only
373+
// egress. The guard must pin the reachable IPv4 instead.
374+
mockDnsLookup.mockResolvedValue([
375+
{ address: '2606:4700:3037::ac43:cc5f', family: 6 },
376+
{ address: '104.21.22.105', family: 4 },
377+
])
378+
await expect(validateMcpServerSsrf('https://app.withgauge.com/mcp')).resolves.toBe(
379+
'104.21.22.105'
380+
)
381+
})
382+
383+
it('pins the sole IPv6 address for an IPv6-only host', async () => {
384+
mockDnsLookup.mockResolvedValue([{ address: '2606:4700:3037::ac43:cc5f', family: 6 }])
385+
await expect(validateMcpServerSsrf('https://ipv6-only.example/mcp')).resolves.toBe(
386+
'2606:4700:3037::ac43:cc5f'
387+
)
388+
})
389+
371390
it('returns resolved IP for HTTP URLs on non-localhost hosts', async () => {
372-
mockDnsLookup.mockResolvedValue({ address: '93.184.216.34' })
391+
mockDnsLookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }])
373392
await expect(validateMcpServerSsrf('http://example.com:3000/mcp')).resolves.toBe(
374393
'93.184.216.34'
375394
)
@@ -405,12 +424,12 @@ describe('validateMcpServerSsrf', () => {
405424
})
406425

407426
it('throws McpSsrfError for URLs resolving to private IPs', async () => {
408-
mockDnsLookup.mockResolvedValue({ address: '10.0.0.5' })
427+
mockDnsLookup.mockResolvedValue([{ address: '10.0.0.5', family: 4 }])
409428
await expect(validateMcpServerSsrf('https://internal.corp/mcp')).rejects.toThrow(McpSsrfError)
410429
})
411430

412431
it('throws McpSsrfError for URLs resolving to link-local IPs', async () => {
413-
mockDnsLookup.mockResolvedValue({ address: '169.254.169.254' })
432+
mockDnsLookup.mockResolvedValue([{ address: '169.254.169.254', family: 4 }])
414433
await expect(validateMcpServerSsrf('https://metadata.internal/latest')).rejects.toThrow(
415434
McpSsrfError
416435
)
@@ -424,7 +443,7 @@ describe('validateMcpServerSsrf', () => {
424443
})
425444

426445
it('returns resolved IP for URLs resolving to loopback on self-hosted (localhost alias)', async () => {
427-
mockDnsLookup.mockResolvedValue({ address: '127.0.0.1' })
446+
mockDnsLookup.mockResolvedValue([{ address: '127.0.0.1', family: 4 }])
428447
await expect(validateMcpServerSsrf('http://my-local-alias:3000/mcp')).resolves.toBe('127.0.0.1')
429448
})
430449

@@ -450,14 +469,14 @@ describe('validateMcpServerSsrf', () => {
450469
})
451470

452471
it('rejects URLs resolving to loopback on hosted', async () => {
453-
mockDnsLookup.mockResolvedValue({ address: '127.0.0.1' })
472+
mockDnsLookup.mockResolvedValue([{ address: '127.0.0.1', family: 4 }])
454473
await expect(validateMcpServerSsrf('http://my-local-alias:3000/mcp')).rejects.toThrow(
455474
McpSsrfError
456475
)
457476
})
458477

459478
it('returns resolved IP for public IP resolutions on hosted', async () => {
460-
mockDnsLookup.mockResolvedValue({ address: '93.184.216.34' })
479+
mockDnsLookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }])
461480
await expect(validateMcpServerSsrf('https://example.com/mcp')).resolves.toBe('93.184.216.34')
462481
})
463482

@@ -483,7 +502,7 @@ describe('validateMcpServerSsrf', () => {
483502
})
484503

485504
it('still blocks DNS resolutions to private IPs on hosted (regression)', async () => {
486-
mockDnsLookup.mockResolvedValue({ address: '10.0.0.5' })
505+
mockDnsLookup.mockResolvedValue([{ address: '10.0.0.5', family: 4 }])
487506
await expect(validateMcpServerSsrf('https://internal.corp/mcp')).rejects.toThrow(McpSsrfError)
488507
})
489508

@@ -508,7 +527,7 @@ describe('validateMcpServerSsrf', () => {
508527
})
509528

510529
it('returns resolved loopback IP for DNS aliases (caller pins)', async () => {
511-
mockDnsLookup.mockResolvedValue({ address: '127.0.0.1' })
530+
mockDnsLookup.mockResolvedValue([{ address: '127.0.0.1', family: 4 }])
512531
await expect(validateMcpServerSsrf('http://my-local-alias/mcp')).resolves.toBe('127.0.0.1')
513532
})
514533
})

apps/sim/lib/mcp/domain-check.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -185,8 +185,10 @@ export async function validateMcpServerSsrf(url: string | undefined): Promise<st
185185

186186
let address: string
187187
try {
188-
const lookup = await dns.lookup(cleanHostname, { verbatim: true })
189-
address = lookup.address
188+
// Prefer IPv4: pinning strips Happy Eyeballs' fallback, and a pinned IPv6 address
189+
// (which `verbatim` returns first for dual-stack hosts) hangs on IPv4-only egress.
190+
const resolved = await dns.lookup(cleanHostname, { all: true, verbatim: true })
191+
address = (resolved.find((entry) => entry.family === 4) ?? resolved[0]).address
190192
} catch (error) {
191193
logger.warn('DNS lookup failed for MCP server URL', {
192194
hostname,

0 commit comments

Comments
 (0)