Skip to content

Commit 8b376a0

Browse files
committed
fix(mcp): permit the pinned IP as a redirect target (initial + hops), block other private IPs
Consolidates the pinned-path redirect policy into one mechanism. followRedirectsGuarded took validateInitialTarget to skip the initial private-IP check, but per-hop checks still blocked a self-hosted MCP redirecting to its own pinned private IP (e.g. a trailing-slash 301 to http://10.0.0.5/mcp/). Replace it with allowRedirectToIp: the pinned fetch permits exactly its own validated IP as a target — initial URL and any hop that stays on it — while every OTHER private target (e.g. the 169.254.169.254 metadata IP) stays blocked. Tests cover the same-IP hop (followed) and the metadata-IP escape (still refused).
1 parent 39c7515 commit 8b376a0

2 files changed

Lines changed: 51 additions & 14 deletions

File tree

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

Lines changed: 23 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -543,7 +543,7 @@ const MAX_GUARDED_REDIRECTS = 5
543543
* a 3xx to `http://169.254.169.254/` would otherwise connect directly. Hostname
544544
* targets are covered by {@link createSsrfGuardedLookup} at connect time.
545545
*/
546-
function assertGuardedRedirectTarget(url: URL): void {
546+
function assertGuardedRedirectTarget(url: URL, allowedPinnedIp?: string): void {
547547
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
548548
throw new Error(`Blocked by SSRF policy: redirect to unsupported protocol ${url.protocol}`)
549549
}
@@ -552,6 +552,16 @@ function assertGuardedRedirectTarget(url: URL): void {
552552
? url.hostname.slice(1, -1)
553553
: url.hostname
554554
if (ipaddr.isValid(host) && isPrivateOrReservedIP(host)) {
555+
// The pinned-private carve-out permits exactly its own validated IP as a target (a
556+
// self-hosted MCP on a private IP, or a same-host redirect that stays on it) — but nothing
557+
// else private (a redirect to e.g. the cloud metadata IP is still blocked).
558+
if (
559+
allowedPinnedIp &&
560+
ipaddr.isValid(allowedPinnedIp) &&
561+
ipaddr.process(host).toString() === ipaddr.process(allowedPinnedIp).toString()
562+
) {
563+
return
564+
}
555565
throw new Error('Blocked by SSRF policy: redirect to a private or reserved address')
556566
}
557567
}
@@ -568,15 +578,14 @@ export async function followRedirectsGuarded(
568578
rawFetch: (url: string, init: UndiciRequestInit) => Promise<Response>,
569579
input: string,
570580
init: UndiciRequestInit,
571-
options?: { validateInitialTarget?: boolean }
581+
options?: { allowRedirectToIp?: string }
572582
): Promise<Response> {
573583
let currentUrl = new URL(input)
574-
// The initial URL gets the same IP-literal check as redirect hops, so the exported
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)
584+
// The initial URL gets the same IP-literal check as redirect hops, so the exported guard is
585+
// self-contained even when a caller skips its own up-front validation. `allowRedirectToIp`
586+
// (the pinned-private MCP carve-out's validated IP) permits that one private target — both the
587+
// initial URL and any hop that stays on it — while everything else private stays blocked.
588+
assertGuardedRedirectTarget(currentUrl, options?.allowRedirectToIp)
580589
let method = (init.method ?? 'GET').toUpperCase()
581590
let body = init.body
582591
let headers = init.headers
@@ -604,7 +613,7 @@ export async function followRedirectsGuarded(
604613
throw new Error(`Blocked by SSRF policy: more than ${MAX_GUARDED_REDIRECTS} redirects`)
605614
}
606615
const nextUrl = new URL(location, currentUrl)
607-
assertGuardedRedirectTarget(nextUrl)
616+
assertGuardedRedirectTarget(nextUrl, options?.allowRedirectToIp)
608617
// Per the fetch spec: 303 (and 301/302 on POST) switch to a bodyless GET, dropping
609618
// the entity headers that described the removed body (a retained Content-Length /
610619
// Content-Type on a bodyless GET is malformed and undici rejects it).
@@ -1033,11 +1042,11 @@ export function createPinnedFetchWithDispatcher(
10331042
}
10341043
return response
10351044
}
1036-
// The pinned fetch's caller has already validated the target (and the private carve-out
1037-
// legitimately pins to a private IP), so skip re-validating the initial URL — otherwise a
1038-
// self-hosted MCP configured with a private IP-literal URL would be blocked by its own
1039-
// transport. Redirect hops are still validated inside the follower.
1040-
return followRedirectsGuarded(rawFetch, target, undiciInit, { validateInitialTarget: false })
1045+
// Permit this pinned IP as a redirect/initial target even when it's private (the
1046+
// self-hosted MCP carve-out on a private/loopback IP, and same-host redirects that stay on
1047+
// it) — otherwise the guarded policy would block a self-hosted server reaching itself. Any
1048+
// OTHER private target (e.g. a redirect to the cloud metadata IP) is still blocked.
1049+
return followRedirectsGuarded(rawFetch, target, undiciInit, { allowRedirectToIp: resolvedIP })
10411050
}
10421051

10431052
return { fetch: pinned, dispatcher }

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

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,34 @@ describe('createPinnedFetch', () => {
176176
expect(await response.text()).toBe('mcp')
177177
})
178178

179+
it('follows a redirect that stays on the pinned private IP (self-hosted MCP alias)', async () => {
180+
mockUndiciRequest
181+
.mockResolvedValueOnce(
182+
undiciReply(301, { location: 'http://10.0.0.5:3000/mcp/' }, byteStream(''))
183+
)
184+
.mockResolvedValueOnce(undiciReply(200, {}, byteStream('mcp')))
185+
const pinned = createPinnedFetch('10.0.0.5')
186+
187+
const response = await pinned('http://10.0.0.5:3000/mcp', { method: 'GET' })
188+
189+
expect(mockUndiciRequest).toHaveBeenCalledTimes(2)
190+
expect(response.status).toBe(200)
191+
expect(await response.text()).toBe('mcp')
192+
})
193+
194+
it('STILL blocks a redirect to a different private IP (no metadata-IP escape)', async () => {
195+
mockUndiciRequest.mockResolvedValueOnce(
196+
undiciReply(302, { location: 'http://169.254.169.254/latest/meta-data/' }, byteStream(''))
197+
)
198+
const pinned = createPinnedFetch('10.0.0.5')
199+
200+
await expect(pinned('http://10.0.0.5:3000/mcp', { method: 'GET' })).rejects.toThrow(
201+
/private or reserved/
202+
)
203+
// The initial request happened; the redirect to the metadata IP was refused.
204+
expect(mockUndiciRequest).toHaveBeenCalledTimes(1)
205+
})
206+
179207
it('reuses one dispatcher across all calls of a single instance', async () => {
180208
const pinned = createPinnedFetch('203.0.113.10')
181209
await pinned('https://example.com/a')

0 commit comments

Comments
 (0)