Skip to content

Commit 8ada731

Browse files
committed
fix(mcp): pin (not skip) self-hosted private resolutions; refuse cross-origin body forwards
- The private/loopback carve-out now keeps the LEGACY PIN to the validated address instead of falling back to unguarded fetch — preserving both the old behavior and its anti-rebinding property for self-hosted DNS aliases. - Cross-origin redirect hops now also refuse to forward a request body (307/308 preserve method+body; post-pin those redirects really dial the new origin, so an open redirect could exfiltrate OAuth client secrets). Bodyless cross-origin redirects still follow. Tests for both.
1 parent ccde12e commit 8ada731

5 files changed

Lines changed: 82 additions & 24 deletions

File tree

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

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -530,7 +530,17 @@ export async function followRedirectsGuarded(
530530
headers = sanitized as unknown as UndiciRequestInit['headers']
531531
}
532532
}
533-
if (nextUrl.origin !== currentUrl.origin) headers = undefined
533+
if (nextUrl.origin !== currentUrl.origin) {
534+
headers = undefined
535+
// 307/308 preserve method+body; forwarding a body cross-origin can hand OAuth
536+
// client secrets / tokens to an open-redirect target now that redirects really
537+
// dial the new origin. No legitimate MCP/OAuth flow does this — refuse it.
538+
if (body !== undefined && body !== null) {
539+
throw new Error(
540+
'Blocked by SSRF policy: cross-origin redirect would forward a request body'
541+
)
542+
}
543+
}
534544
currentUrl = nextUrl
535545
}
536546
}

apps/sim/lib/core/security/ssrf-guarded-lookup.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,3 +209,24 @@ describe('followRedirectsGuarded — hardening', () => {
209209
expect(hopHeaders.get('x-keep')).toBe('yes')
210210
})
211211
})
212+
213+
describe('followRedirectsGuarded — cross-origin body protection', () => {
214+
it('refuses a cross-origin 307 that would forward a request body', async () => {
215+
const raw = vi.fn(async () => redirectTo('https://b.example/steal', 307))
216+
await expect(
217+
followRedirectsGuarded(raw, 'https://a.example/token', {
218+
method: 'POST',
219+
body: 'client_secret=shh',
220+
})
221+
).rejects.toThrow(/cross-origin redirect would forward a request body/)
222+
})
223+
224+
it('allows a bodyless cross-origin redirect', async () => {
225+
const raw = vi
226+
.fn()
227+
.mockResolvedValueOnce(redirectTo('https://b.example/next', 302))
228+
.mockResolvedValueOnce(new Response('ok', { status: 200 }))
229+
const res = await followRedirectsGuarded(raw, 'https://a.example/x', {})
230+
expect(res.status).toBe(200)
231+
})
232+
})

apps/sim/lib/mcp/client.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import { getMaxExecutionTimeout } from '@/lib/core/execution-limits'
1414
import { isPrivateOrReservedIP } from '@/lib/core/security/input-validation.server'
1515
import { getMcpSafeErrorDiagnostics } from '@/lib/mcp/error-diagnostics'
1616
import { McpOauthRedirectRequired } from '@/lib/mcp/oauth'
17-
import { createGuardedMcpFetch } from '@/lib/mcp/pinned-fetch'
17+
import { createGuardedMcpFetch, createPinnedPrivateMcpFetch } from '@/lib/mcp/pinned-fetch'
1818
import {
1919
type McpClientOptions,
2020
McpConnectionError,
@@ -100,10 +100,13 @@ export class McpClient {
100100
// `resolvedIP` non-null signals the SSRF policy is active for this server (it is null in
101101
// allowlist mode / localhost-on-self-hosted); the guard validates addresses per-connect.
102102
// A private/loopback resolvedIP only reaches here on self-hosted (where the policy
103-
// permits it) — the guarded lookup would filter it, so those connect unguarded like the
104-
// localhost carve-out.
105-
const guarded =
106-
resolvedIP && !isPrivateOrReservedIP(resolvedIP) ? createGuardedMcpFetch() : undefined
103+
// permits it) — the guarded lookup would filter it, so that case keeps the legacy pin
104+
// to the validated address (old behavior + its anti-rebinding property).
105+
const guarded = resolvedIP
106+
? isPrivateOrReservedIP(resolvedIP)
107+
? createPinnedPrivateMcpFetch(resolvedIP)
108+
: createGuardedMcpFetch()
109+
: undefined
107110
this.closeGuardedTransport = guarded?.close
108111
this.transport = new StreamableHTTPClientTransport(new URL(this.config.url), {
109112
authProvider: useOauth ? this.authProvider : undefined,

apps/sim/lib/mcp/pinned-fetch.test.ts

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,21 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
66

77
const {
88
mockCreateGuardedFetchWithDispatcher,
9+
mockCreatePinnedFetchWithDispatcher,
910
mockValidateMcpServerSsrf,
1011
sentinelFetch,
1112
mockDestroy,
1213
} = vi.hoisted(() => ({
1314
mockCreateGuardedFetchWithDispatcher: vi.fn(),
15+
mockCreatePinnedFetchWithDispatcher: vi.fn(),
1416
mockValidateMcpServerSsrf: vi.fn(),
1517
sentinelFetch: vi.fn(),
1618
mockDestroy: vi.fn(),
1719
}))
1820

1921
vi.mock('@/lib/core/security/input-validation.server', () => ({
2022
createSsrfGuardedFetchWithDispatcher: mockCreateGuardedFetchWithDispatcher,
23+
createPinnedFetchWithDispatcher: mockCreatePinnedFetchWithDispatcher,
2124
isPrivateOrReservedIP: (ip: string) =>
2225
ip.startsWith('127.') || ip.startsWith('10.') || ip === '::1',
2326
}))
@@ -349,20 +352,22 @@ describe('createSsrfGuardedMcpFetch', () => {
349352
})
350353

351354
describe('self-hosted private-resolution carve-out', () => {
352-
it('routes a loopback-resolving host over global fetch (guarded lookup would filter it)', async () => {
353-
// Self-hosted DNS alias -> 127.0.0.1: policy allows it, so the guard must not
354-
// strand the connect. Falls back to global fetch, same as the allowlist path.
355+
it('keeps the legacy pin for a loopback-resolving host (guarded lookup would filter it)', async () => {
356+
// Self-hosted DNS alias -> 127.0.0.1: policy allows it. The guarded lookup would
357+
// strand the connect and an unguarded fallback would reopen rebinding — so this case
358+
// pins to the validated address, preserving the old behavior and its security property.
355359
mockValidateMcpServerSsrf.mockResolvedValue('127.0.0.1')
356-
const globalFetch = vi
357-
.spyOn(globalThis, 'fetch')
358-
.mockImplementation(async () => new Response('ok'))
359-
try {
360-
const fetchLike = createSsrfGuardedMcpFetch()
361-
await fetchLike('https://my-local-alias/mcp')
362-
expect(mockCreateGuardedFetchWithDispatcher).not.toHaveBeenCalled()
363-
expect(globalFetch).toHaveBeenCalledTimes(1)
364-
} finally {
365-
globalFetch.mockRestore()
366-
}
360+
mockCreatePinnedFetchWithDispatcher.mockReturnValue({
361+
fetch: sentinelFetch,
362+
dispatcher: { destroy: mockDestroy },
363+
})
364+
sentinelFetch.mockImplementation(async () => new Response('ok'))
365+
const fetchLike = createSsrfGuardedMcpFetch()
366+
await fetchLike('https://my-local-alias/mcp')
367+
expect(mockCreatePinnedFetchWithDispatcher).toHaveBeenCalledWith(
368+
'127.0.0.1',
369+
expect.objectContaining({ maxResponseSize: expect.any(Number) })
370+
)
371+
expect(mockCreateGuardedFetchWithDispatcher).not.toHaveBeenCalled()
367372
})
368373
})

apps/sim/lib/mcp/pinned-fetch.ts

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js'
22
import { createLogger } from '@sim/logger'
33
import type { Agent } from 'undici'
44
import {
5+
createPinnedFetchWithDispatcher,
56
createSsrfGuardedFetchWithDispatcher,
67
isPrivateOrReservedIP,
78
} from '@/lib/core/security/input-validation.server'
@@ -35,6 +36,18 @@ export interface GuardedMcpFetch {
3536
* the transport has no concurrency to gain from h2. `close` tears down pooled sockets
3637
* (incl. the SSE connection) on disconnect.
3738
*/
39+
/**
40+
* Legacy single-IP pin, kept ONLY for self-hosted private/loopback resolutions
41+
* (a DNS alias the policy explicitly permits): the guarded lookup would filter
42+
* the address and strand the connect, while an unguarded fallback would reopen
43+
* rebinding/redirect escape. Pinning to the validated address preserves the old
44+
* behavior and its security property for exactly this carve-out.
45+
*/
46+
export function createPinnedPrivateMcpFetch(resolvedIP: string): GuardedMcpFetch {
47+
const { fetch: pinnedFetch, dispatcher } = createPinnedFetchWithDispatcher(resolvedIP)
48+
return { fetch: pinnedFetch, close: () => dispatcher.destroy() }
49+
}
50+
3851
export function createGuardedMcpFetch(): GuardedMcpFetch {
3952
const { fetch: guardedFetch, dispatcher } = createSsrfGuardedFetchWithDispatcher()
4053
// Per-request phase logging: a stalled transport request (e.g. a first `initialize` that hangs
@@ -219,10 +232,16 @@ export function createSsrfGuardedMcpFetch(timeoutMs: number = OAUTH_FETCH_TIMEOU
219232
const resolvedIP = await withDeadline(validateMcpServerSsrf(target), signal)
220233
logger.info('OAuth guarded fetch: requesting', { host, guarded: Boolean(resolvedIP) })
221234
let response: Response
222-
// A private/loopback resolvedIP only occurs on self-hosted where the policy permits
223-
// it — the guarded lookup would filter it out, so those go over global fetch like the
224-
// allowlist carve-out below.
225-
if (resolvedIP && !isPrivateOrReservedIP(resolvedIP)) {
235+
if (resolvedIP && isPrivateOrReservedIP(resolvedIP)) {
236+
// Self-hosted private/loopback resolution (policy-permitted): the guarded lookup
237+
// would filter the address, and an unguarded fallback would reopen rebinding —
238+
// keep the legacy pin to the validated address for exactly this case.
239+
const pinned = createPinnedFetchWithDispatcher(resolvedIP, {
240+
maxResponseSize: MAX_OAUTH_RESPONSE_BYTES,
241+
})
242+
dispatcher = pinned.dispatcher
243+
response = await withDeadline(pinned.fetch(url, { ...init, signal }), signal)
244+
} else if (resolvedIP) {
226245
const guarded = createSsrfGuardedFetchWithDispatcher({
227246
maxResponseSize: MAX_OAUTH_RESPONSE_BYTES,
228247
})

0 commit comments

Comments
 (0)