Skip to content

Commit 128943d

Browse files
committed
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.
1 parent ceac9a2 commit 128943d

2 files changed

Lines changed: 109 additions & 69 deletions

File tree

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

Lines changed: 67 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-
interceptors as undiciInterceptors,
1817
request as undiciRequest,
1918
} from 'undici'
2019
import { isHosted, isPrivateDatabaseHostsAllowed } from '@/lib/core/config/env-flags'
@@ -587,7 +586,13 @@ export async function followRedirectsGuarded(
587586
})
588587
const status = response.status
589588
const location = response.headers.get('location')
590-
if (![301, 302, 303, 307, 308].includes(status) || !location) return response
589+
if (![301, 302, 303, 307, 308].includes(status) || !location) {
590+
// `response.url` is already the final hop's URL (set per-request by the raw fetch); flag
591+
// `redirected` too when at least one hop was followed, matching fetch semantics.
592+
if (hop > 0)
593+
Object.defineProperty(response, 'redirected', { value: true, configurable: true })
594+
return response
595+
}
591596
// Cancel the redirect body up front so the throw paths below (hop cap, blocked
592597
// target) can't leave a socket checked out on the long-lived Agent.
593598
await response.body?.cancel().catch(() => {})
@@ -880,6 +885,33 @@ async function undiciRequestAsResponse(
880885
}
881886
}
882887

888+
/**
889+
* Normalizes a `fetch(input, init)` call into a URL string + init. A `Request` input carries
890+
* its own method/headers/body/signal; lift them into the init (explicit init fields win, per
891+
* fetch semantics) so a manual redirect follower can't silently downgrade a POST Request to a
892+
* bare GET or lose its headers.
893+
*/
894+
async function liftFetchArgs(
895+
input: RequestInfo | URL,
896+
init?: RequestInit
897+
): Promise<{ target: string; effectiveInit: RequestInit }> {
898+
const target = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url
899+
if (typeof Request !== 'undefined' && input instanceof Request) {
900+
const bodyAllowed = input.method !== 'GET' && input.method !== 'HEAD'
901+
return {
902+
target,
903+
effectiveInit: {
904+
method: input.method,
905+
headers: input.headers,
906+
body: bodyAllowed ? await input.clone().arrayBuffer() : undefined,
907+
signal: input.signal,
908+
...init,
909+
},
910+
}
911+
}
912+
return { target, effectiveInit: init ?? {} }
913+
}
914+
883915
/**
884916
* SSRF-guarded `fetch` + its `Agent` for outbound requests to user-controlled
885917
* hosts: DNS resolves normally, and every socket connect validates the chosen
@@ -903,21 +935,7 @@ export function createSsrfGuardedFetchWithDispatcher(options?: { maxResponseSize
903935
undiciRequestAsResponse(url, init as unknown as RequestInit, dispatcher)
904936

905937
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-
}
938+
const { target, effectiveInit } = await liftFetchArgs(input, init)
921939
// double-cast-allowed: DOM RequestInit and undici RequestInit are structurally compatible at runtime but the TS types differ
922940
return followRedirectsGuarded(rawFetch, target, effectiveInit as unknown as UndiciRequestInit)
923941
}
@@ -977,21 +995,40 @@ export function createPinnedFetchWithDispatcher(
977995
...(options?.maxResponseSize !== undefined ? { maxResponseSize: options.maxResponseSize } : {}),
978996
})
979997

998+
const rawFetch = (url: string, init: UndiciRequestInit): Promise<Response> =>
999+
// double-cast-allowed: DOM RequestInit and undici RequestInit differ in TS but match at runtime
1000+
undiciRequestAsResponse(url, init as unknown as RequestInit, dispatcher)
1001+
9801002
// Requests go through `undici.request` (not `undici.fetch`) because fetch's streaming
9811003
// `response.body` never delivers under the Bun runtime the server runs on — the same bug
982-
// {@link createSsrfGuardedFetchWithDispatcher} works around. Unlike the guarded builder, the
983-
// pinned fetch is handed straight to provider/A2A SDKs with no `followRedirectsGuarded`
984-
// wrapper, so redirects are followed here via undici's redirect interceptor. Every hop still
985-
// dispatches through the pinned `Agent` (its `connect.lookup` forces `resolvedIP`), so a
986-
// redirect can't escape to another address — matching the old fetch path's guarantee.
987-
const redirecting = dispatcher.compose(
988-
undiciInterceptors.redirect({ maxRedirections: DEFAULT_MAX_REDIRECTS })
989-
)
990-
const pinned = (input: RequestInfo | URL, init?: RequestInit): Promise<Response> =>
991-
undiciRequestAsResponse(input, init ?? {}, redirecting)
992-
993-
// Return the base `Agent` (not the composed dispatcher) so callers `destroy()` the socket
994-
// owner on close; the interceptor is stateless and re-dispatches through it.
1004+
// {@link createSsrfGuardedFetchWithDispatcher} works around. Redirects are handled here (not
1005+
// by a caller's wrapper — the pinned fetch is passed straight to provider/A2A SDKs), honoring
1006+
// the request's `redirect` mode: `manual`/`error` must NOT transparently follow (e.g.
1007+
// `detectMcpAuthType` inspects the 3xx to classify auth). The default `follow` uses
1008+
// {@link followRedirectsGuarded}, which drops headers on cross-origin hops (so a redirect
1009+
// can't disclose a provider `api-key` to another origin) and stamps the final `response.url`.
1010+
// Every hop still dispatches through the pinned `Agent` (its `connect.lookup` forces
1011+
// `resolvedIP`), so a redirect can't escape to another address.
1012+
const pinned = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
1013+
const { target, effectiveInit } = await liftFetchArgs(input, init)
1014+
const mode = effectiveInit.redirect ?? 'follow'
1015+
// double-cast-allowed: DOM RequestInit and undici RequestInit are structurally compatible at runtime but the TS types differ
1016+
const undiciInit = effectiveInit as unknown as UndiciRequestInit
1017+
if (mode === 'manual') {
1018+
return rawFetch(target, undiciInit)
1019+
}
1020+
if (mode === 'error') {
1021+
const response = await rawFetch(target, undiciInit)
1022+
const location = response.headers.get('location')
1023+
if (response.status >= 300 && response.status < 400 && location) {
1024+
await response.body?.cancel().catch(() => {})
1025+
throw new TypeError('Pinned fetch received an unexpected redirect (redirect: "error")')
1026+
}
1027+
return response
1028+
}
1029+
return followRedirectsGuarded(rawFetch, target, undiciInit)
1030+
}
1031+
9951032
return { fetch: pinned, dispatcher }
9961033
}
9971034

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

Lines changed: 42 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -4,49 +4,27 @@
44
import { Readable } from 'node:stream'
55
import { beforeEach, describe, expect, it, vi } from 'vitest'
66

7-
const {
8-
mockAgent,
9-
mockUndiciRequest,
10-
mockRedirectInterceptor,
11-
capturedAgentOptions,
12-
capturedRedirectOptions,
13-
} = vi.hoisted(() => {
7+
const { mockAgent, mockUndiciRequest, capturedAgentOptions } = vi.hoisted(() => {
148
const capturedAgentOptions: unknown[] = []
15-
const capturedRedirectOptions: unknown[] = []
169
class MockAgent {
1710
constructor(options: unknown) {
1811
capturedAgentOptions.push(options)
1912
}
20-
// The pinned builder follows redirects by composing this Agent with the redirect
21-
// interceptor; the composed dispatcher is what reaches undici.request.
22-
compose(interceptor: unknown) {
23-
return { __composed: true, base: this, interceptor }
24-
}
2513
close() {
2614
return Promise.resolve()
2715
}
2816
destroy() {
2917
return Promise.resolve()
3018
}
3119
}
32-
const mockRedirectInterceptor = vi.fn((options: unknown) => {
33-
capturedRedirectOptions.push(options)
34-
return { __redirectInterceptor: true }
35-
})
3620
return {
3721
mockAgent: MockAgent,
3822
mockUndiciRequest: vi.fn(),
39-
mockRedirectInterceptor,
4023
capturedAgentOptions,
41-
capturedRedirectOptions,
4224
}
4325
})
4426

45-
vi.mock('undici', () => ({
46-
Agent: mockAgent,
47-
request: mockUndiciRequest,
48-
interceptors: { redirect: mockRedirectInterceptor },
49-
}))
27+
vi.mock('undici', () => ({ Agent: mockAgent, request: mockUndiciRequest }))
5028

5129
declare module '@/lib/core/security/input-validation.server?pinned-fetch-test' {
5230
// biome-ignore lint/suspicious/noExportsInTest: ambient re-declaration for the query-suffixed specifier
@@ -73,7 +51,6 @@ describe('createPinnedFetch', () => {
7351
beforeEach(() => {
7452
vi.clearAllMocks()
7553
capturedAgentOptions.length = 0
76-
capturedRedirectOptions.length = 0
7754
mockUndiciRequest.mockResolvedValue(undiciReply(200, {}, byteStream('ok')))
7855
})
7956

@@ -113,13 +90,7 @@ describe('createPinnedFetch', () => {
11390
expect(resolved).toEqual({ address: '2606:4700:4700::1111', family: 6 })
11491
})
11592

116-
it('follows redirects through the pinned Agent (interceptor composed with the app max)', () => {
117-
createPinnedFetch('203.0.113.10')
118-
expect(mockRedirectInterceptor).toHaveBeenCalledTimes(1)
119-
expect(capturedRedirectOptions[0]).toEqual({ maxRedirections: 5 })
120-
})
121-
122-
it('dispatches through the composed (redirect-following) dispatcher, preserving init', async () => {
93+
it('dispatches through the pinned Agent, preserving init', async () => {
12394
const pinned = createPinnedFetch('203.0.113.10')
12495
const controller = new AbortController()
12596

@@ -133,22 +104,54 @@ describe('createPinnedFetch', () => {
133104
expect(mockUndiciRequest).toHaveBeenCalledTimes(1)
134105
const [url, options] = mockUndiciRequest.mock.calls[0]
135106
expect(url).toBe('https://myresource.openai.azure.com/openai/v1/responses')
136-
expect((options.dispatcher as { __composed?: boolean }).__composed).toBe(true)
137-
expect((options.dispatcher as { base: unknown }).base).toBeInstanceOf(mockAgent)
107+
expect(options.dispatcher).toBeInstanceOf(mockAgent)
138108
expect(options.method).toBe('POST')
139109
expect(options.headers).toEqual({ 'api-key': 'secret' })
140110
expect(options.body).toBe('{}')
141111
expect(options.signal).toBe(controller.signal)
142112
})
143113

144-
it('handles an undefined init by still dispatching through the pinned dispatcher', async () => {
114+
it('honors redirect: "manual" — returns the 3xx without following (auth-type probe)', async () => {
115+
mockUndiciRequest.mockResolvedValueOnce(
116+
undiciReply(302, { location: 'https://login.example.com/' }, byteStream(''))
117+
)
118+
const pinned = createPinnedFetch('203.0.113.10')
119+
120+
const response = await pinned('https://mcp.example.com/', { redirect: 'manual' })
121+
122+
expect(mockUndiciRequest).toHaveBeenCalledTimes(1)
123+
expect(response.status).toBe(302)
124+
expect(response.headers.get('location')).toBe('https://login.example.com/')
125+
})
126+
127+
it('follows redirects by default and DROPS headers on a cross-origin hop (no api-key leak)', async () => {
128+
mockUndiciRequest
129+
.mockResolvedValueOnce(
130+
undiciReply(307, { location: 'https://other-origin.example/final' }, byteStream(''))
131+
)
132+
.mockResolvedValueOnce(undiciReply(200, {}, byteStream('done')))
145133
const pinned = createPinnedFetch('203.0.113.10')
146-
await pinned('https://example.com')
147-
const options = mockUndiciRequest.mock.calls[0][1] as { dispatcher: { __composed?: boolean } }
148-
expect(options.dispatcher.__composed).toBe(true)
134+
135+
const response = await pinned('https://azure.example.com/v1/responses', {
136+
method: 'GET',
137+
headers: { 'api-key': 'secret' },
138+
})
139+
140+
expect(mockUndiciRequest).toHaveBeenCalledTimes(2)
141+
// Second (cross-origin) hop must not carry the provider credential — no headers forwarded.
142+
const secondHopHeaders = (mockUndiciRequest.mock.calls[1][1].headers ?? {}) as Record<
143+
string,
144+
string
145+
>
146+
expect(secondHopHeaders['api-key']).toBeUndefined()
147+
expect(Object.keys(secondHopHeaders)).toHaveLength(0)
148+
expect(response.status).toBe(200)
149+
expect(response.url).toBe('https://other-origin.example/final')
150+
expect(response.redirected).toBe(true)
151+
expect(await response.text()).toBe('done')
149152
})
150153

151-
it('reuses one composed dispatcher across all calls of a single instance', async () => {
154+
it('reuses one dispatcher across all calls of a single instance', async () => {
152155
const pinned = createPinnedFetch('203.0.113.10')
153156
await pinned('https://example.com/a')
154157
await pinned('https://example.com/b')

0 commit comments

Comments
 (0)