Skip to content

Commit f6bd96b

Browse files
committed
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.
1 parent 6f96d4a commit f6bd96b

7 files changed

Lines changed: 374 additions & 59 deletions

File tree

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

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

107107
try {
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]
108+
const { address } = await dns.lookup(cleanHostname, { verbatim: true })
112109

113110
const resolvedIsLoopback =
114111
ipaddr.isValid(address) &&
@@ -422,6 +419,136 @@ export function createPinnedLookup(resolvedIP: string): LookupFunction {
422419
}
423420
}
424421

422+
/**
423+
* DNS lookup that resolves normally and validates EVERY resolved address against
424+
* the SSRF policy at socket-connect time (the LibreChat `getSSRFConnect` pattern).
425+
* Private/reserved/loopback records are filtered out; if nothing publicly routable
426+
* remains the connect fails. Because the check runs on each dial — including
427+
* redirects and reconnects — there is no validated-then-trusted window for a DNS
428+
* rebind to slip through, and unlike single-IP pinning the connector keeps the
429+
* full public address set, so the OS/undici can fall back across addresses.
430+
* IPv4 is ordered first (`verbatim: false`) — our egress is IPv4-only.
431+
*/
432+
export function createSsrfGuardedLookup(): LookupFunction {
433+
return (hostname, options, callback) => {
434+
dns
435+
.lookup(hostname, { all: true, verbatim: false })
436+
.then((addresses) => {
437+
const usable = addresses.filter((entry) => !isPrivateOrReservedIP(entry.address))
438+
if (usable.length === 0) {
439+
callback(
440+
new Error(`Blocked by SSRF policy: ${hostname} has no publicly routable address`),
441+
'',
442+
4
443+
)
444+
return
445+
}
446+
if (options.all) callback(null, usable)
447+
else callback(null, usable[0].address, usable[0].family)
448+
})
449+
.catch((error) => callback(toError(error), '', 4))
450+
}
451+
}
452+
453+
const MAX_GUARDED_REDIRECTS = 5
454+
455+
/**
456+
* Rejects a redirect hop whose target is a private/reserved IP LITERAL. Node's
457+
* `net.connect` bypasses the custom `lookup` for numeric hosts (`isIP(host)`
458+
* short-circuits), so the connect-time guard never sees IP-literal dials —
459+
* a 3xx to `http://169.254.169.254/` would otherwise connect directly. Hostname
460+
* targets are covered by {@link createSsrfGuardedLookup} at connect time.
461+
*/
462+
function assertGuardedRedirectTarget(url: URL): void {
463+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
464+
throw new Error(`Blocked by SSRF policy: redirect to unsupported protocol ${url.protocol}`)
465+
}
466+
const host =
467+
url.hostname.startsWith('[') && url.hostname.endsWith(']')
468+
? url.hostname.slice(1, -1)
469+
: url.hostname
470+
if (ipaddr.isValid(host) && isPrivateOrReservedIP(host)) {
471+
throw new Error('Blocked by SSRF policy: redirect to a private or reserved address')
472+
}
473+
}
474+
475+
/**
476+
* Manual, revalidating redirect follower used by the guarded fetch. Auto-follow
477+
* is unsafe here on two counts the connect-time lookup cannot cover: IP-literal
478+
* redirect targets bypass the lookup entirely (validated per hop instead), and
479+
* undici retains CUSTOM request headers across cross-origin redirects (it strips
480+
* only Authorization/Cookie) — so caller headers are dropped on any cross-origin
481+
* hop. Exported for tests.
482+
*/
483+
export async function followRedirectsGuarded(
484+
rawFetch: (url: string, init: UndiciRequestInit) => Promise<Response>,
485+
input: string,
486+
init: UndiciRequestInit
487+
): Promise<Response> {
488+
let currentUrl = new URL(input)
489+
let method = (init.method ?? 'GET').toUpperCase()
490+
let body = init.body
491+
let headers = init.headers
492+
for (let hop = 0; ; hop++) {
493+
const response = await rawFetch(currentUrl.href, {
494+
...init,
495+
method,
496+
body,
497+
headers,
498+
redirect: 'manual',
499+
})
500+
const status = response.status
501+
const location = response.headers.get('location')
502+
if (![301, 302, 303, 307, 308].includes(status) || !location) return response
503+
if (hop >= MAX_GUARDED_REDIRECTS) {
504+
throw new Error(`Blocked by SSRF policy: more than ${MAX_GUARDED_REDIRECTS} redirects`)
505+
}
506+
const nextUrl = new URL(location, currentUrl)
507+
assertGuardedRedirectTarget(nextUrl)
508+
await response.body?.cancel().catch(() => {})
509+
// Per the fetch spec: 303 (and 301/302 on POST) switch to a bodyless GET.
510+
if (status === 303 || ((status === 301 || status === 302) && method === 'POST')) {
511+
method = 'GET'
512+
body = undefined
513+
}
514+
if (nextUrl.origin !== currentUrl.origin) headers = undefined
515+
currentUrl = nextUrl
516+
}
517+
}
518+
519+
/**
520+
* SSRF-guarded `fetch` + its `Agent` for outbound requests to user-controlled
521+
* hosts: DNS resolves normally, and every socket connect validates the chosen
522+
* addresses via {@link createSsrfGuardedLookup}; redirects are followed manually
523+
* with per-hop validation (see {@link followRedirectsGuarded}) so IP-literal
524+
* targets can't bypass the lookup and custom headers never cross origins. See
525+
* {@link createPinnedFetchWithDispatcher} for the `maxResponseSize` semantics.
526+
*/
527+
export function createSsrfGuardedFetchWithDispatcher(options?: { maxResponseSize?: number }): {
528+
fetch: typeof fetch
529+
dispatcher: Agent
530+
} {
531+
const dispatcher = new Agent({
532+
allowH2: false,
533+
connect: { lookup: createSsrfGuardedLookup() },
534+
...(options?.maxResponseSize !== undefined ? { maxResponseSize: options.maxResponseSize } : {}),
535+
})
536+
537+
const rawFetch = async (url: string, init: UndiciRequestInit): Promise<Response> => {
538+
const response = await undiciFetch(url, { ...init, dispatcher })
539+
// double-cast-allowed: undici Response and DOM Response are structurally compatible at runtime
540+
return response as unknown as Response
541+
}
542+
543+
const guarded = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
544+
const target = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url
545+
// double-cast-allowed: DOM RequestInit and undici RequestInit are structurally compatible at runtime but the TS types differ
546+
return followRedirectsGuarded(rawFetch, target, (init ?? {}) as unknown as UndiciRequestInit)
547+
}
548+
549+
return { fetch: guarded, dispatcher }
550+
}
551+
425552
/**
426553
* Builds a standard `fetch`-compatible function that pins every outbound
427554
* connection to `resolvedIP`, preventing DNS-rebinding (TOCTOU) between URL
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { beforeEach, describe, expect, it, vi } from 'vitest'
5+
6+
const { mockDnsLookup } = vi.hoisted(() => ({ mockDnsLookup: vi.fn() }))
7+
8+
vi.mock('dns/promises', () => ({ default: { lookup: mockDnsLookup } }))
9+
10+
import { createSsrfGuardedLookup } from '@/lib/core/security/input-validation.server'
11+
12+
type LookupResult = { address: string; family: number }
13+
14+
function runLookup(
15+
hostname: string,
16+
options: { all?: boolean } = {}
17+
): Promise<{ err: Error | null; address?: string | LookupResult[]; family?: number }> {
18+
return new Promise((resolve) => {
19+
const lookup = createSsrfGuardedLookup()
20+
type LookupCb = (err: Error | null, address?: string | LookupResult[], family?: number) => void
21+
// double-cast-allowed: net.LookupFunction's overloaded callback shapes collapse to this in practice
22+
;(lookup as unknown as (h: string, o: object, cb: LookupCb) => void)(
23+
hostname,
24+
options,
25+
(err: Error | null, address?: string | LookupResult[], family?: number) =>
26+
resolve({ err, address, family })
27+
)
28+
})
29+
}
30+
31+
describe('createSsrfGuardedLookup', () => {
32+
beforeEach(() => {
33+
vi.clearAllMocks()
34+
})
35+
36+
it('passes through a public address', async () => {
37+
mockDnsLookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }])
38+
const r = await runLookup('example.com')
39+
expect(r.err).toBeNull()
40+
expect(r.address).toBe('93.184.216.34')
41+
expect(r.family).toBe(4)
42+
})
43+
44+
it.each([
45+
['loopback', '127.0.0.1'],
46+
['RFC1918 10.x', '10.0.0.5'],
47+
['RFC1918 192.168.x', '192.168.1.1'],
48+
['link-local metadata', '169.254.169.254'],
49+
['IPv6 loopback', '::1'],
50+
['IPv4-mapped private', '::ffff:10.0.0.1'],
51+
])('fails the connect when the host resolves only to %s', async (_label, ip) => {
52+
mockDnsLookup.mockResolvedValue([{ address: ip, family: ip.includes(':') ? 6 : 4 }])
53+
const r = await runLookup('rebind.attacker.example')
54+
expect(r.err?.message).toMatch(/Blocked by SSRF policy/)
55+
})
56+
57+
it('filters private records out of a mixed answer and connects only to public ones', async () => {
58+
// A rebinding server can interleave private records with public ones; only the
59+
// public set may ever reach the socket.
60+
mockDnsLookup.mockResolvedValue([
61+
{ address: '10.1.2.3', family: 4 },
62+
{ address: '93.184.216.34', family: 4 },
63+
{ address: '169.254.169.254', family: 4 },
64+
])
65+
const r = await runLookup('mixed.example', { all: true })
66+
expect(r.err).toBeNull()
67+
expect(r.address).toEqual([{ address: '93.184.216.34', family: 4 }])
68+
})
69+
70+
it('re-validates on every call (no validated-then-trusted window)', async () => {
71+
// First resolution is public; the rebind flips to private — the second connect fails.
72+
mockDnsLookup.mockResolvedValueOnce([{ address: '93.184.216.34', family: 4 }])
73+
mockDnsLookup.mockResolvedValueOnce([{ address: '169.254.169.254', family: 4 }])
74+
75+
const first = await runLookup('rebind.example')
76+
const second = await runLookup('rebind.example')
77+
78+
expect(first.err).toBeNull()
79+
expect(second.err?.message).toMatch(/Blocked by SSRF policy/)
80+
})
81+
82+
it('propagates DNS resolution failures', async () => {
83+
mockDnsLookup.mockRejectedValue(new Error('ENOTFOUND'))
84+
const r = await runLookup('nope.invalid')
85+
expect(r.err?.message).toBe('ENOTFOUND')
86+
})
87+
88+
it('returns the full public set for options.all (fallback across addresses)', async () => {
89+
mockDnsLookup.mockResolvedValue([
90+
{ address: '104.21.22.105', family: 4 },
91+
{ address: '172.67.204.95', family: 4 },
92+
])
93+
const r = await runLookup('multi.example', { all: true })
94+
expect(r.err).toBeNull()
95+
expect(r.address).toHaveLength(2)
96+
})
97+
})
98+
99+
import { followRedirectsGuarded } from '@/lib/core/security/input-validation.server'
100+
101+
function redirectTo(location: string, status = 302): Response {
102+
return new Response(null, { status, headers: { location } })
103+
}
104+
105+
describe('followRedirectsGuarded', () => {
106+
it('returns a non-redirect response as-is', async () => {
107+
const raw = vi.fn(async () => new Response('ok', { status: 200 }))
108+
const res = await followRedirectsGuarded(raw, 'https://a.example/x', {})
109+
expect(res.status).toBe(200)
110+
expect(raw).toHaveBeenCalledTimes(1)
111+
})
112+
113+
it('follows a same-origin redirect and keeps the caller headers', async () => {
114+
const raw = vi
115+
.fn()
116+
.mockResolvedValueOnce(redirectTo('https://a.example/y'))
117+
.mockResolvedValueOnce(new Response('ok', { status: 200 }))
118+
const res = await followRedirectsGuarded(raw, 'https://a.example/x', {
119+
headers: { 'x-api-key': 'secret' },
120+
})
121+
expect(res.status).toBe(200)
122+
expect(raw.mock.calls[1][0]).toBe('https://a.example/y')
123+
expect(raw.mock.calls[1][1].headers).toEqual({ 'x-api-key': 'secret' })
124+
})
125+
126+
it('drops custom headers on a cross-origin redirect', async () => {
127+
const raw = vi
128+
.fn()
129+
.mockResolvedValueOnce(redirectTo('https://b.example/harvest'))
130+
.mockResolvedValueOnce(new Response('ok', { status: 200 }))
131+
await followRedirectsGuarded(raw, 'https://a.example/x', {
132+
headers: { 'x-api-key': 'secret' },
133+
})
134+
expect(raw.mock.calls[1][1].headers).toBeUndefined()
135+
})
136+
137+
it('blocks a redirect to a private IP literal (metadata endpoint)', async () => {
138+
const raw = vi.fn(async () => redirectTo('http://169.254.169.254/latest/meta-data/'))
139+
await expect(followRedirectsGuarded(raw, 'https://a.example/x', {})).rejects.toThrow(
140+
/Blocked by SSRF policy/
141+
)
142+
expect(raw).toHaveBeenCalledTimes(1)
143+
})
144+
145+
it('blocks a redirect to a bracketed private IPv6 literal', async () => {
146+
const raw = vi.fn(async () => redirectTo('http://[::1]/admin'))
147+
await expect(followRedirectsGuarded(raw, 'https://a.example/x', {})).rejects.toThrow(
148+
/Blocked by SSRF policy/
149+
)
150+
})
151+
152+
it('blocks non-http(s) redirect protocols', async () => {
153+
const raw = vi.fn(async () => redirectTo('file:///etc/passwd'))
154+
await expect(followRedirectsGuarded(raw, 'https://a.example/x', {})).rejects.toThrow(
155+
/unsupported protocol/
156+
)
157+
})
158+
159+
it('caps the number of hops', async () => {
160+
const raw = vi.fn(async () => redirectTo('https://a.example/loop'))
161+
await expect(followRedirectsGuarded(raw, 'https://a.example/x', {})).rejects.toThrow(
162+
/more than \d+ redirects/
163+
)
164+
})
165+
166+
it('switches POST to a bodyless GET on 303', async () => {
167+
const raw = vi
168+
.fn()
169+
.mockResolvedValueOnce(redirectTo('https://a.example/next', 303))
170+
.mockResolvedValueOnce(new Response('ok', { status: 200 }))
171+
await followRedirectsGuarded(raw, 'https://a.example/x', { method: 'POST', body: 'data' })
172+
expect(raw.mock.calls[1][1].method).toBe('GET')
173+
expect(raw.mock.calls[1][1].body).toBeUndefined()
174+
})
175+
176+
it('preserves method and body on 307', async () => {
177+
const raw = vi
178+
.fn()
179+
.mockResolvedValueOnce(redirectTo('https://a.example/next', 307))
180+
.mockResolvedValueOnce(new Response('ok', { status: 200 }))
181+
await followRedirectsGuarded(raw, 'https://a.example/x', { method: 'POST', body: 'data' })
182+
expect(raw.mock.calls[1][1].method).toBe('POST')
183+
expect(raw.mock.calls[1][1].body).toBe('data')
184+
})
185+
})

apps/sim/lib/mcp/client.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ vi.mock('@sim/logger', () => ({
2121
}))
2222

2323
vi.mock('@/lib/mcp/pinned-fetch', () => ({
24-
createPinnedMcpFetch: vi.fn(() => ({ fetch: vi.fn(), close: mockPinnedClose })),
24+
createGuardedMcpFetch: vi.fn(() => ({ fetch: vi.fn(), close: mockPinnedClose })),
2525
}))
2626

2727
/**

apps/sim/lib/mcp/client.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { getErrorMessage } from '@sim/utils/errors'
1313
import { getMaxExecutionTimeout } from '@/lib/core/execution-limits'
1414
import { getMcpSafeErrorDiagnostics } from '@/lib/mcp/error-diagnostics'
1515
import { McpOauthRedirectRequired } from '@/lib/mcp/oauth'
16-
import { createPinnedMcpFetch } from '@/lib/mcp/pinned-fetch'
16+
import { createGuardedMcpFetch } from '@/lib/mcp/pinned-fetch'
1717
import {
1818
type McpClientOptions,
1919
McpConnectionError,
@@ -73,7 +73,7 @@ export class McpClient {
7373
private onToolsChanged?: McpToolsChangedCallback
7474
private authProvider?: McpClientOptions['authProvider']
7575
private isConnected = false
76-
private closePinnedTransport?: () => Promise<void>
76+
private closeGuardedTransport?: () => Promise<void>
7777

7878
constructor(options: McpClientOptions) {
7979
this.config = options.config
@@ -96,12 +96,14 @@ export class McpClient {
9696
throw new McpError('OAuth MCP server requires an authProvider')
9797
}
9898
const useOauth = this.config.authType === 'oauth'
99-
const pinned = resolvedIP ? createPinnedMcpFetch(resolvedIP) : undefined
100-
this.closePinnedTransport = pinned?.close
99+
// `resolvedIP` non-null signals the SSRF policy is active for this server (it is null in
100+
// allowlist mode / localhost-on-self-hosted); the guard validates addresses per-connect.
101+
const guarded = resolvedIP ? createGuardedMcpFetch() : undefined
102+
this.closeGuardedTransport = guarded?.close
101103
this.transport = new StreamableHTTPClientTransport(new URL(this.config.url), {
102104
authProvider: useOauth ? this.authProvider : undefined,
103105
requestInit: { headers: this.config.headers },
104-
...(pinned ? { fetch: pinned.fetch } : {}),
106+
...(guarded ? { fetch: guarded.fetch } : {}),
105107
})
106108

107109
this.client = new Client(
@@ -235,9 +237,9 @@ export class McpClient {
235237
* followed by the caller's `disconnect()`) never destroy the same Agent twice.
236238
*/
237239
private async closeTransportAgent(): Promise<void> {
238-
const close = this.closePinnedTransport
240+
const close = this.closeGuardedTransport
239241
if (!close) return
240-
this.closePinnedTransport = undefined
242+
this.closeGuardedTransport = undefined
241243
try {
242244
await close()
243245
} catch (error) {

0 commit comments

Comments
 (0)