Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion apps/sim/lib/core/security/input-validation.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,10 @@ export async function validateUrlWithDNS(
}

try {
const { address } = await dns.lookup(cleanHostname, { verbatim: true })
// 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]
Comment thread
greptile-apps[bot] marked this conversation as resolved.

const resolvedIsLoopback =
ipaddr.isValid(address) &&
Expand Down
37 changes: 28 additions & 9 deletions apps/sim/lib/mcp/domain-check.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
)
Expand Down Expand Up @@ -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
)
Expand All @@ -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')
})

Expand All @@ -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')
})

Expand All @@ -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)
})

Expand All @@ -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')
})
})
Expand Down
6 changes: 4 additions & 2 deletions apps/sim/lib/mcp/domain-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,8 +185,10 @@ export async function validateMcpServerSsrf(url: string | undefined): Promise<st

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