Skip to content

Commit 4fa5fde

Browse files
committed
fix(mcp): bound OAuth discovery/DCR/token fetches with a timeout
The MCP SDK issues OAuth discovery, dynamic client registration, and token exchange with a bare fetch and no AbortSignal (only the JSON-RPC layer gets the SDK's request timeout), and undici's default headers/body timeouts are 5 min. A slow or unresponsive authorization server therefore left /oauth/start pending for minutes — the browser stuck on "Connecting…" forever. Bound each guarded OAuth/revocation leg with a 30s AbortSignal.timeout, composed with any caller signal so cancellation still works. Only our own deadline is relabeled to an McpError; caller aborts and other failures propagate unchanged. Scoped to createSsrfGuardedMcpFetch (OAuth/revoke/probe) — the live transport's timeouts are untouched.
1 parent 031a295 commit 4fa5fde

2 files changed

Lines changed: 94 additions & 10 deletions

File tree

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

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,62 @@ describe('createSsrfGuardedMcpFetch', () => {
3232

3333
expect(mockValidateMcpServerSsrf).toHaveBeenCalledWith('https://attacker.example/revoke')
3434
expect(mockCreatePinnedFetch).toHaveBeenCalledWith('203.0.113.10')
35-
expect(sentinelFetch).toHaveBeenCalledWith('https://attacker.example/revoke', {
36-
method: 'POST',
37-
})
35+
expect(sentinelFetch).toHaveBeenCalledWith(
36+
'https://attacker.example/revoke',
37+
expect.objectContaining({ method: 'POST', signal: expect.any(AbortSignal) })
38+
)
39+
})
40+
41+
it('attaches an abort signal to every guarded request even without a caller signal', async () => {
42+
mockValidateMcpServerSsrf.mockResolvedValue('203.0.113.10')
43+
const fetchLike = createSsrfGuardedMcpFetch()
44+
await fetchLike('https://attacker.example/discover')
45+
46+
const [, init] = sentinelFetch.mock.calls[0]
47+
expect(init.signal).toBeInstanceOf(AbortSignal)
48+
})
49+
50+
it('surfaces an McpError when a request exceeds the deadline', async () => {
51+
mockValidateMcpServerSsrf.mockResolvedValue('203.0.113.10')
52+
// Hang until the guard's own deadline aborts the request.
53+
sentinelFetch.mockImplementation(
54+
(_url: string, init: RequestInit) =>
55+
new Promise((_resolve, reject) => {
56+
const signal = init.signal
57+
if (signal?.aborted) {
58+
reject(signal.reason)
59+
return
60+
}
61+
signal?.addEventListener('abort', () => reject(signal.reason), { once: true })
62+
})
63+
)
64+
const fetchLike = createSsrfGuardedMcpFetch(5)
65+
66+
await expect(fetchLike('https://slow.example/token', { method: 'POST' })).rejects.toThrow(
67+
/timed out after 5ms/
68+
)
69+
})
70+
71+
it('propagates a caller-initiated abort unchanged (composed with the deadline)', async () => {
72+
mockValidateMcpServerSsrf.mockResolvedValue('203.0.113.10')
73+
sentinelFetch.mockImplementation(
74+
(_url: string, init: RequestInit) =>
75+
new Promise((_resolve, reject) => {
76+
const signal = init.signal
77+
if (signal?.aborted) {
78+
reject(signal.reason)
79+
return
80+
}
81+
signal?.addEventListener('abort', () => reject(signal.reason), { once: true })
82+
})
83+
)
84+
const controller = new AbortController()
85+
// Long deadline so the caller's abort — not the timeout — is what settles the request.
86+
const fetchLike = createSsrfGuardedMcpFetch(60_000)
87+
const pending = fetchLike('https://slow.example/token', { signal: controller.signal })
88+
controller.abort(new Error('caller cancelled'))
89+
90+
await expect(pending).rejects.toThrow('caller cancelled')
3891
})
3992

4093
it('rejects URLs that resolve to blocked IPs without issuing the request', async () => {

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

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
createPinnedFetchWithDispatcher,
55
} from '@/lib/core/security/input-validation.server'
66
import { validateMcpServerSsrf } from '@/lib/mcp/domain-check'
7+
import { McpError } from '@/lib/mcp/types'
78

89
/** Pinned fetch for the live MCP transport, plus a handle to release its sockets. */
910
export interface PinnedMcpFetch {
@@ -31,6 +32,20 @@ export function createPinnedMcpFetch(resolvedIP: string): PinnedMcpFetch {
3132
return { fetch: pinnedFetch, close: () => dispatcher.destroy() }
3233
}
3334

35+
/**
36+
* Per-request deadline for guarded MCP OAuth / RFC 7009 revocation HTTP calls.
37+
*
38+
* The MCP SDK issues OAuth discovery, dynamic client registration, and token
39+
* exchange with a bare `fetch` and no `AbortSignal` — only the JSON-RPC message
40+
* layer gets the SDK's request timeout. Combined with undici's 5-minute default
41+
* headers/body timeouts, a slow or unresponsive authorization server leaves the
42+
* request (and the browser the user is waiting on during `/oauth/start`) pending
43+
* for minutes. Bounding each leg turns that into a fast, actionable failure. 30s
44+
* mirrors `MCP_CLIENT_CONSTANTS.DEFAULT_CONNECTION_TIMEOUT` and leaves wide
45+
* headroom over a healthy server, which completes each leg in well under a second.
46+
*/
47+
const OAUTH_FETCH_TIMEOUT_MS = 30_000
48+
3449
/**
3550
* Builds a `FetchLike` that validates every outbound request URL against the
3651
* MCP SSRF policy before issuing it, then pins the connection to the resolved
@@ -42,19 +57,35 @@ export function createPinnedMcpFetch(resolvedIP: string): PinnedMcpFetch {
4257
* per request and rejects private/reserved/loopback targets (honoring
4358
* `ALLOWED_MCP_DOMAINS` and self-hosted localhost rules).
4459
*
45-
* Note: a caller-provided `AbortSignal` in `init` only bounds the HTTP request,
46-
* not the validation DNS lookup — Node's `dns.lookup` does not accept a signal,
47-
* so a hanging resolution can extend the overall call past the caller's timeout
48-
* by up to the OS DNS timeout. Acceptable here because all consumers are
49-
* best-effort, non-blocking flows (OAuth discovery and RFC 7009 revocation).
60+
* Each request is bounded by a `timeoutMs` deadline via `AbortSignal.timeout`,
61+
* composed with any caller-provided signal so cancellation still works. Only our
62+
* own deadline is relabeled to an `McpError`; a caller abort or any other failure
63+
* propagates unchanged.
64+
*
65+
* Note: the deadline only bounds the HTTP request, not the validation DNS lookup
66+
* — Node's `dns.lookup` does not accept a signal, so a hanging resolution can
67+
* extend the overall call by up to the OS DNS timeout. Acceptable here because
68+
* all consumers are best-effort authorization/revocation flows.
5069
*
70+
* @param timeoutMs Per-request deadline in ms (defaults to 30s; override for tests).
5171
* @throws McpSsrfError if a request URL resolves to a blocked IP address
72+
* @throws McpError if a request exceeds `timeoutMs`
5273
*/
53-
export function createSsrfGuardedMcpFetch(): FetchLike {
74+
export function createSsrfGuardedMcpFetch(timeoutMs: number = OAUTH_FETCH_TIMEOUT_MS): FetchLike {
5475
return (async (url, init) => {
5576
const target = typeof url === 'string' ? url : url.href
5677
const resolvedIP = await validateMcpServerSsrf(target)
5778
const pinnedFetch: FetchLike = resolvedIP ? createPinnedFetch(resolvedIP) : globalThis.fetch
58-
return pinnedFetch(url, init)
79+
const timeoutSignal = AbortSignal.timeout(timeoutMs)
80+
const signal = init?.signal ? AbortSignal.any([init.signal, timeoutSignal]) : timeoutSignal
81+
try {
82+
return await pinnedFetch(url, { ...init, signal })
83+
} catch (error) {
84+
if (timeoutSignal.aborted && !init?.signal?.aborted) {
85+
const host = URL.canParse(target) ? new URL(target).host : target
86+
throw new McpError(`MCP authorization request to ${host} timed out after ${timeoutMs}ms`)
87+
}
88+
throw error
89+
}
5990
}) satisfies FetchLike
6091
}

0 commit comments

Comments
 (0)