Skip to content

Commit 4187225

Browse files
authored
fix(mcp): bound and tear down one-shot OAuth fetches so auth can't hang (#5789)
The SSRF-guarded fetch used for MCP OAuth (discovery, DCR, token exchange/ refresh, RFC 7009 revocation) created a fresh pinned undici Agent per request and never tore it down, and returned the live response so the SDK's lazy body read happened outside any deadline. The MCP SDK sets no timeout on OAuth legs, so a stalled body read or a leaked keep-alive socket could leave the flow — and the browser waiting on it — pending indefinitely ("Connecting… forever"). - Buffer the (always-small) OAuth JSON body inside the guard, under the composed deadline/caller AbortSignal, then return a detached in-memory Response. undici's bodyTimeout only measures idle gaps between chunks and cannot bound a slow-drip body; the AbortSignal is the only true wall-clock deadline over the body read. - Destroy (not close) the per-request pinned Agent on every path so a one-shot leg can't strand its keep-alive socket, and teardown itself can't hang. - Fix the same per-request Agent leak in the auth-type probe. - Returning a normalized global Response also surfaces provider token-endpoint errors cleanly instead of the SDK's opaque parse failure.
1 parent 2798467 commit 4187225

6 files changed

Lines changed: 392 additions & 73 deletions

File tree

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -452,14 +452,20 @@ export function createPinnedFetch(
452452
* caller with a defined connection lifetime (e.g. a long-lived MCP transport) can
453453
* tear the Agent down on close instead of waiting for its idle timeout. Closing
454454
* the Agent is what releases any pooled keep-alive / HTTP/2 sockets it holds.
455+
*
456+
* `maxResponseSize` caps the (decoded) response body in bytes and makes undici reject
457+
* with `UND_ERR_RES_EXCEEDED_MAX_SIZE` once exceeded — a DoS backstop for one-shot
458+
* callers reading from a URL taken from untrusted metadata. Omit it (the default) to
459+
* leave the response unbounded, which streaming consumers like the MCP transport need.
455460
*/
456461
export function createPinnedFetchWithDispatcher(
457462
resolvedIP: string,
458-
options?: { allowH2?: boolean }
463+
options?: { allowH2?: boolean; maxResponseSize?: number }
459464
): { fetch: typeof fetch; dispatcher: Agent } {
460465
const dispatcher = new Agent({
461466
allowH2: options?.allowH2 ?? false,
462467
connect: { lookup: createPinnedLookup(resolvedIP) },
468+
...(options?.maxResponseSize !== undefined ? { maxResponseSize: options.maxResponseSize } : {}),
463469
})
464470

465471
const pinned = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {

apps/sim/lib/mcp/oauth/probe.test.ts

Lines changed: 25 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,30 @@
33
*/
44
import { beforeEach, describe, expect, it, vi } from 'vitest'
55

6-
const { mockCreatePinnedFetch, mockCreateSsrfGuardedMcpFetch, mockPinnedFetch, mockGuardedFetch } =
7-
vi.hoisted(() => {
8-
const mockPinnedFetch = vi.fn()
9-
const mockGuardedFetch = vi.fn()
10-
return {
11-
mockPinnedFetch,
12-
mockGuardedFetch,
13-
mockCreatePinnedFetch: vi.fn(() => mockPinnedFetch),
14-
mockCreateSsrfGuardedMcpFetch: vi.fn(() => mockGuardedFetch),
15-
}
16-
})
6+
const {
7+
mockCreatePinnedFetchWithDispatcher,
8+
mockCreateSsrfGuardedMcpFetch,
9+
mockPinnedFetch,
10+
mockGuardedFetch,
11+
mockDestroy,
12+
} = vi.hoisted(() => {
13+
const mockPinnedFetch = vi.fn()
14+
const mockGuardedFetch = vi.fn()
15+
const mockDestroy = vi.fn(() => Promise.resolve())
16+
return {
17+
mockPinnedFetch,
18+
mockGuardedFetch,
19+
mockDestroy,
20+
mockCreatePinnedFetchWithDispatcher: vi.fn(() => ({
21+
fetch: mockPinnedFetch,
22+
dispatcher: { destroy: mockDestroy },
23+
})),
24+
mockCreateSsrfGuardedMcpFetch: vi.fn(() => mockGuardedFetch),
25+
}
26+
})
1727

1828
vi.mock('@/lib/core/security/input-validation.server', () => ({
19-
createPinnedFetch: mockCreatePinnedFetch,
29+
createPinnedFetchWithDispatcher: mockCreatePinnedFetchWithDispatcher,
2030
}))
2131
vi.mock('@/lib/mcp/pinned-fetch', () => ({
2232
createSsrfGuardedMcpFetch: mockCreateSsrfGuardedMcpFetch,
@@ -48,7 +58,7 @@ describe('detectMcpAuthType — connection pinning (SSRF / DNS-rebinding)', () =
4858
const authType = await detectMcpAuthType('https://rebind.example.com/mcp', '203.0.113.10')
4959

5060
expect(authType).toBe('none')
51-
expect(mockCreatePinnedFetch).toHaveBeenCalledWith('203.0.113.10')
61+
expect(mockCreatePinnedFetchWithDispatcher).toHaveBeenCalledWith('203.0.113.10')
5262
expect(mockCreateSsrfGuardedMcpFetch).not.toHaveBeenCalled()
5363
expect(mockPinnedFetch).toHaveBeenCalledTimes(1)
5464
// The unpinned global fetch must never be used — that was the SSRF sink.
@@ -62,7 +72,7 @@ describe('detectMcpAuthType — connection pinning (SSRF / DNS-rebinding)', () =
6272

6373
expect(authType).toBe('none')
6474
expect(mockCreateSsrfGuardedMcpFetch).toHaveBeenCalledTimes(1)
65-
expect(mockCreatePinnedFetch).not.toHaveBeenCalled()
75+
expect(mockCreatePinnedFetchWithDispatcher).not.toHaveBeenCalled()
6676
expect(mockGuardedFetch).toHaveBeenCalledTimes(1)
6777
expect(globalFetchSpy).not.toHaveBeenCalled()
6878
})
@@ -88,7 +98,7 @@ describe('detectMcpAuthType — connection pinning (SSRF / DNS-rebinding)', () =
8898
const authType = await detectMcpAuthType('http://example.com/mcp', '203.0.113.10')
8999

90100
expect(authType).toBe('headers')
91-
expect(mockCreatePinnedFetch).not.toHaveBeenCalled()
101+
expect(mockCreatePinnedFetchWithDispatcher).not.toHaveBeenCalled()
92102
expect(mockCreateSsrfGuardedMcpFetch).not.toHaveBeenCalled()
93103
expect(globalFetchSpy).not.toHaveBeenCalled()
94104
})

apps/sim/lib/mcp/oauth/probe.ts

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { extractWWWAuthenticateParams } from '@modelcontextprotocol/sdk/client/auth.js'
22
import type { FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js'
33
import { createLogger } from '@sim/logger'
4-
import { createPinnedFetch } from '@/lib/core/security/input-validation.server'
4+
import { createPinnedFetchWithDispatcher } from '@/lib/core/security/input-validation.server'
55
import { isLoopbackHostname } from '@/lib/core/utils/urls'
66
import { createSsrfGuardedMcpFetch } from '@/lib/mcp/pinned-fetch'
77
import type { McpAuthType } from '@/lib/mcp/types'
@@ -33,12 +33,17 @@ export async function detectMcpAuthType(
3333
return 'headers'
3434
}
3535

36-
const probeFetch: FetchLike = resolvedIP
37-
? createPinnedFetch(resolvedIP)
38-
: createSsrfGuardedMcpFetch()
36+
// When the caller pre-validated the IP we pin to it directly and own the Agent's
37+
// lifetime; the SSRF-guarded fetch (used when we must validate ourselves) manages its
38+
// own per-request Agent teardown internally.
39+
const pinned = resolvedIP ? createPinnedFetchWithDispatcher(resolvedIP) : undefined
40+
const probeFetch: FetchLike = pinned?.fetch ?? createSsrfGuardedMcpFetch()
3941

4042
const controller = new AbortController()
4143
const timer = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS)
44+
// Best-effort session cleanup runs after we return the classification; the pinned
45+
// Agent must outlive it, so we tear it down once cleanup settles.
46+
let sessionClose: Promise<void> = Promise.resolve()
4247

4348
try {
4449
const res = await probeFetch(url, {
@@ -63,7 +68,7 @@ export async function detectMcpAuthType(
6368

6469
const sessionId = res.headers.get('mcp-session-id')
6570
if (sessionId) {
66-
void closeMcpSession(url, sessionId, probeFetch)
71+
sessionClose = closeMcpSession(url, sessionId, probeFetch)
6772
}
6873

6974
if (res.status === 401) {
@@ -82,6 +87,11 @@ export async function detectMcpAuthType(
8287
return 'headers'
8388
} finally {
8489
clearTimeout(timer)
90+
// Destroy (not close) so a hung request can't make teardown itself hang; wait for
91+
// the best-effort session-close hop first so it isn't aborted mid-flight.
92+
if (pinned) {
93+
void sessionClose.finally(() => pinned.dispatcher.destroy().catch(() => {}))
94+
}
8595
}
8696
}
8797

apps/sim/lib/mcp/oauth/revoke.test.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,10 @@ const {
3131
}))
3232

3333
vi.mock('@/lib/core/security/input-validation.server', () => ({
34-
createPinnedFetch: vi.fn(() => mockUndiciFetch),
34+
createPinnedFetchWithDispatcher: vi.fn(() => ({
35+
fetch: mockUndiciFetch,
36+
dispatcher: { destroy: vi.fn(() => Promise.resolve()) },
37+
})),
3538
}))
3639
vi.mock('@/lib/mcp/domain-check', () => ({
3740
validateMcpServerSsrf: mockValidateMcpServerSsrf,

0 commit comments

Comments
 (0)