Skip to content

Commit 80b5d08

Browse files
committed
fix(mcp): don't block private IP-literal URLs on the pinned fetch path
Routing the pinned fetch through followRedirectsGuarded added an initial assertGuardedRedirectTarget check the old undici.fetch path never ran, which would block a self-hosted MCP configured with a private IP-literal URL (e.g. http://10.0.0.5:3000/mcp) — its own transport. The pinned path's callers already validate the target and the private carve-out intentionally pins to a private IP, so skip the initial-target check (validateInitialTarget: false) while still validating every redirect hop. Adds a regression test.
1 parent 128943d commit 80b5d08

2 files changed

Lines changed: 25 additions & 4 deletions

File tree

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

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -567,12 +567,16 @@ function assertGuardedRedirectTarget(url: URL): void {
567567
export async function followRedirectsGuarded(
568568
rawFetch: (url: string, init: UndiciRequestInit) => Promise<Response>,
569569
input: string,
570-
init: UndiciRequestInit
570+
init: UndiciRequestInit,
571+
options?: { validateInitialTarget?: boolean }
571572
): Promise<Response> {
572573
let currentUrl = new URL(input)
573574
// The initial URL gets the same IP-literal check as redirect hops, so the exported
574-
// guard is self-contained even when a caller skips its own up-front validation.
575-
assertGuardedRedirectTarget(currentUrl)
575+
// guard is self-contained even when a caller skips its own up-front validation. The
576+
// pinned-private MCP carve-out opts out (`validateInitialTarget: false`): its caller has
577+
// already validated the URL and legitimately targets a private IP (a self-hosted server
578+
// configured with an IP-literal URL). Redirect HOPS below are always validated regardless.
579+
if (options?.validateInitialTarget !== false) assertGuardedRedirectTarget(currentUrl)
576580
let method = (init.method ?? 'GET').toUpperCase()
577581
let body = init.body
578582
let headers = init.headers
@@ -1026,7 +1030,11 @@ export function createPinnedFetchWithDispatcher(
10261030
}
10271031
return response
10281032
}
1029-
return followRedirectsGuarded(rawFetch, target, undiciInit)
1033+
// The pinned fetch's caller has already validated the target (and the private carve-out
1034+
// legitimately pins to a private IP), so skip re-validating the initial URL — otherwise a
1035+
// self-hosted MCP configured with a private IP-literal URL would be blocked by its own
1036+
// transport. Redirect hops are still validated inside the follower.
1037+
return followRedirectsGuarded(rawFetch, target, undiciInit, { validateInitialTarget: false })
10301038
}
10311039

10321040
return { fetch: pinned, dispatcher }

apps/sim/lib/core/security/pinned-fetch.server.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,19 @@ describe('createPinnedFetch', () => {
151151
expect(await response.text()).toBe('done')
152152
})
153153

154+
it('does NOT block a private IP-literal URL (self-hosted-private MCP carve-out)', async () => {
155+
mockUndiciRequest.mockResolvedValueOnce(undiciReply(200, {}, byteStream('mcp')))
156+
const pinned = createPinnedFetch('10.0.0.5')
157+
158+
// A self-hosted MCP configured with a private IP-literal URL must still connect — the old
159+
// undici.fetch path never ran the SSRF initial-target check that would otherwise block it.
160+
const response = await pinned('http://10.0.0.5:3000/mcp', { method: 'POST', body: '{}' })
161+
162+
expect(mockUndiciRequest).toHaveBeenCalledTimes(1)
163+
expect(response.status).toBe(200)
164+
expect(await response.text()).toBe('mcp')
165+
})
166+
154167
it('reuses one dispatcher across all calls of a single instance', async () => {
155168
const pinned = createPinnedFetch('203.0.113.10')
156169
await pinned('https://example.com/a')

0 commit comments

Comments
 (0)