Skip to content

Commit 6f90d89

Browse files
authored
fix(mcp): stream pinned transport under Bun (providers + self-hosted-private MCP) (#5901)
* fix(mcp): stream pinned transport via undici.request + redirect interceptor Extends the Bun undici-streaming fix to createPinnedFetchWithDispatcher (providers, A2A, self-hosted-private MCP over SSE). It now routes through undiciRequestAsResponse like the guarded builder, so streaming bodies deliver under Bun. Unlike the guarded path it has no followRedirectsGuarded wrapper (it's handed straight to provider SDKs), so redirects are followed via undici's redirect interceptor composed onto the pinned Agent — every hop still dispatches through the pinned connect.lookup (resolvedIP), so a redirect can't escape to another address, matching the old fetch guarantee. secureFetchWithPinnedIP (raw Node http, tools path) is untouched. * fix(mcp): honor redirect mode + drop cross-origin credentials on pinned fetch Replaces the always-on redirect interceptor with redirect-mode-aware handling: - redirect:'manual' returns the 3xx without following (detectMcpAuthType inspects it) - redirect:'error' throws on a 3xx - default 'follow' uses followRedirectsGuarded, which drops ALL headers on a cross-origin hop (so a redirect can't disclose a provider api-key to another origin — Greptile P1) and stamps the final response.url + redirected flag. Extracts the shared Request-lift helper used by both guarded and pinned builders. * 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. * fix(mcp): carry redirect mode from a Request input in liftFetchArgs liftFetchArgs copied method/headers/body/signal from a Request but omitted redirect, so a Request({ redirect: 'manual' }) on the pinned path defaulted to 'follow' and was transparently followed. Copy input.redirect (explicit init still wins). Adds a Request-input redirect-mode test. * 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 a5e1030 commit 6f90d89

2 files changed

Lines changed: 218 additions & 67 deletions

File tree

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

Lines changed: 92 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ import {
1414
Agent,
1515
type Dispatcher,
1616
type RequestInit as UndiciRequestInit,
17-
fetch as undiciFetch,
1817
request as undiciRequest,
1918
} from 'undici'
2019
import { isHosted, isPrivateDatabaseHostsAllowed } from '@/lib/core/config/env-flags'
@@ -544,7 +543,7 @@ const MAX_GUARDED_REDIRECTS = 5
544543
* a 3xx to `http://169.254.169.254/` would otherwise connect directly. Hostname
545544
* targets are covered by {@link createSsrfGuardedLookup} at connect time.
546545
*/
547-
function assertGuardedRedirectTarget(url: URL): void {
546+
function assertGuardedRedirectTarget(url: URL, allowedPinnedIp?: string): void {
548547
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
549548
throw new Error(`Blocked by SSRF policy: redirect to unsupported protocol ${url.protocol}`)
550549
}
@@ -553,6 +552,16 @@ function assertGuardedRedirectTarget(url: URL): void {
553552
? url.hostname.slice(1, -1)
554553
: url.hostname
555554
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+
}
556565
throw new Error('Blocked by SSRF policy: redirect to a private or reserved address')
557566
}
558567
}
@@ -568,12 +577,15 @@ function assertGuardedRedirectTarget(url: URL): void {
568577
export async function followRedirectsGuarded(
569578
rawFetch: (url: string, init: UndiciRequestInit) => Promise<Response>,
570579
input: string,
571-
init: UndiciRequestInit
580+
init: UndiciRequestInit,
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.
576-
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)
577589
let method = (init.method ?? 'GET').toUpperCase()
578590
let body = init.body
579591
let headers = init.headers
@@ -587,15 +599,21 @@ export async function followRedirectsGuarded(
587599
})
588600
const status = response.status
589601
const location = response.headers.get('location')
590-
if (![301, 302, 303, 307, 308].includes(status) || !location) return response
602+
if (![301, 302, 303, 307, 308].includes(status) || !location) {
603+
// `response.url` is already the final hop's URL (set per-request by the raw fetch); flag
604+
// `redirected` too when at least one hop was followed, matching fetch semantics.
605+
if (hop > 0)
606+
Object.defineProperty(response, 'redirected', { value: true, configurable: true })
607+
return response
608+
}
591609
// Cancel the redirect body up front so the throw paths below (hop cap, blocked
592610
// target) can't leave a socket checked out on the long-lived Agent.
593611
await response.body?.cancel().catch(() => {})
594612
if (hop >= MAX_GUARDED_REDIRECTS) {
595613
throw new Error(`Blocked by SSRF policy: more than ${MAX_GUARDED_REDIRECTS} redirects`)
596614
}
597615
const nextUrl = new URL(location, currentUrl)
598-
assertGuardedRedirectTarget(nextUrl)
616+
assertGuardedRedirectTarget(nextUrl, options?.allowRedirectToIp)
599617
// Per the fetch spec: 303 (and 301/302 on POST) switch to a bodyless GET, dropping
600618
// the entity headers that described the removed body (a retained Content-Length /
601619
// Content-Type on a bodyless GET is malformed and undici rejects it).
@@ -781,7 +799,7 @@ function nodeReadableToWebStream(nodeStream: Readable): ReadableStream<Uint8Arra
781799
async function undiciRequestAsResponse(
782800
input: RequestInfo | URL,
783801
init: RequestInit,
784-
dispatcher: Agent
802+
dispatcher: Dispatcher
785803
): Promise<Response> {
786804
let url: string
787805
let effectiveInit = init as UndiciRequestInit
@@ -880,6 +898,36 @@ async function undiciRequestAsResponse(
880898
}
881899
}
882900

901+
/**
902+
* Normalizes a `fetch(input, init)` call into a URL string + init. A `Request` input carries
903+
* its own method/headers/body/signal; lift them into the init (explicit init fields win, per
904+
* fetch semantics) so a manual redirect follower can't silently downgrade a POST Request to a
905+
* bare GET or lose its headers.
906+
*/
907+
async function liftFetchArgs(
908+
input: RequestInfo | URL,
909+
init?: RequestInit
910+
): Promise<{ target: string; effectiveInit: RequestInit }> {
911+
const target = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url
912+
if (typeof Request !== 'undefined' && input instanceof Request) {
913+
const bodyAllowed = input.method !== 'GET' && input.method !== 'HEAD'
914+
return {
915+
target,
916+
effectiveInit: {
917+
method: input.method,
918+
headers: input.headers,
919+
body: bodyAllowed ? await input.clone().arrayBuffer() : undefined,
920+
signal: input.signal,
921+
// Carry the Request's redirect mode so the pinned fetch honors `manual`/`error`
922+
// instead of defaulting a `Request({ redirect: 'manual' })` to `follow`.
923+
redirect: input.redirect,
924+
...init,
925+
},
926+
}
927+
}
928+
return { target, effectiveInit: init ?? {} }
929+
}
930+
883931
/**
884932
* SSRF-guarded `fetch` + its `Agent` for outbound requests to user-controlled
885933
* hosts: DNS resolves normally, and every socket connect validates the chosen
@@ -903,21 +951,7 @@ export function createSsrfGuardedFetchWithDispatcher(options?: { maxResponseSize
903951
undiciRequestAsResponse(url, init as unknown as RequestInit, dispatcher)
904952

905953
const guarded = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
906-
const target = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url
907-
// A Request input carries its own method/headers/body/signal; lift them into the
908-
// init (explicit init fields win, per fetch semantics) so the manual redirect
909-
// follower doesn't silently downgrade a guarded POST Request to a bare GET.
910-
let effectiveInit: RequestInit = init ?? {}
911-
if (typeof Request !== 'undefined' && input instanceof Request) {
912-
const bodyAllowed = input.method !== 'GET' && input.method !== 'HEAD'
913-
effectiveInit = {
914-
method: input.method,
915-
headers: input.headers,
916-
body: bodyAllowed ? await input.clone().arrayBuffer() : undefined,
917-
signal: input.signal,
918-
...init,
919-
}
920-
}
954+
const { target, effectiveInit } = await liftFetchArgs(input, init)
921955
// double-cast-allowed: DOM RequestInit and undici RequestInit are structurally compatible at runtime but the TS types differ
922956
return followRedirectsGuarded(rawFetch, target, effectiveInit as unknown as UndiciRequestInit)
923957
}
@@ -977,14 +1011,42 @@ export function createPinnedFetchWithDispatcher(
9771011
...(options?.maxResponseSize !== undefined ? { maxResponseSize: options.maxResponseSize } : {}),
9781012
})
9791013

1014+
const rawFetch = (url: string, init: UndiciRequestInit): Promise<Response> =>
1015+
// double-cast-allowed: DOM RequestInit and undici RequestInit differ in TS but match at runtime
1016+
undiciRequestAsResponse(url, init as unknown as RequestInit, dispatcher)
1017+
1018+
// Requests go through `undici.request` (not `undici.fetch`) because fetch's streaming
1019+
// `response.body` never delivers under the Bun runtime the server runs on — the same bug
1020+
// {@link createSsrfGuardedFetchWithDispatcher} works around. Redirects are handled here (not
1021+
// by a caller's wrapper — the pinned fetch is passed straight to provider/A2A SDKs), honoring
1022+
// the request's `redirect` mode: `manual`/`error` must NOT transparently follow (e.g.
1023+
// `detectMcpAuthType` inspects the 3xx to classify auth). The default `follow` uses
1024+
// {@link followRedirectsGuarded}, which drops headers on cross-origin hops (so a redirect
1025+
// can't disclose a provider `api-key` to another origin) and stamps the final `response.url`.
1026+
// Every hop still dispatches through the pinned `Agent` (its `connect.lookup` forces
1027+
// `resolvedIP`), so a redirect can't escape to another address.
9801028
const pinned = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
981-
// double-cast-allowed: DOM RequestInfo/URL and undici fetch input types differ but are structurally compatible at runtime (Node's global fetch IS undici)
982-
const undiciInput = input as unknown as Parameters<typeof undiciFetch>[0]
1029+
const { target, effectiveInit } = await liftFetchArgs(input, init)
1030+
const mode = effectiveInit.redirect ?? 'follow'
9831031
// double-cast-allowed: DOM RequestInit and undici RequestInit are structurally compatible at runtime but the TS types differ
984-
const undiciInit: UndiciRequestInit = { ...(init as unknown as UndiciRequestInit), dispatcher }
985-
const response = await undiciFetch(undiciInput, undiciInit)
986-
// double-cast-allowed: undici Response and DOM Response are structurally compatible at runtime
987-
return response as unknown as Response
1032+
const undiciInit = effectiveInit as unknown as UndiciRequestInit
1033+
if (mode === 'manual') {
1034+
return rawFetch(target, undiciInit)
1035+
}
1036+
if (mode === 'error') {
1037+
const response = await rawFetch(target, undiciInit)
1038+
const location = response.headers.get('location')
1039+
if (response.status >= 300 && response.status < 400 && location) {
1040+
await response.body?.cancel().catch(() => {})
1041+
throw new TypeError('Pinned fetch received an unexpected redirect (redirect: "error")')
1042+
}
1043+
return response
1044+
}
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 })
9881050
}
9891051

9901052
return { fetch: pinned, dispatcher }

0 commit comments

Comments
 (0)