Skip to content

Commit 922515f

Browse files
authored
fix(mcp): bound OAuth discovery/DCR/token fetches with a timeout (#5776)
* 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. * fix(mcp): bound SSRF/DNS validation by the deadline too Move the timeout signal ahead of validateMcpServerSsrf and race the validation (whose dns.lookup takes no signal) against it, so the deadline covers the whole guarded call — a stalled DNS resolution now rejects at timeoutMs instead of the OS DNS timeout. Listener is cleaned up on settle so a late timeout can't surface as an unhandled rejection. * fix(mcp): compose caller signal before validation + attribute timeout by reason Compose the caller's AbortSignal with the deadline up front and use the combined signal for both SSRF validation and the HTTP request, so caller cancellation now covers the whole guarded call (previously a caller abort during a stalled DNS validation waited for the full deadline). Attribute the timeout relabel by the rejection reason's identity rather than init.signal's state, so a caller signal that aborts just after the deadline can't misattribute a genuine timeout. * fix(mcp): adopt in-flight validation on early abort to avoid unhandled rejection When raceWithSignal is entered with an already-aborted signal it returned without attaching to the in-flight validateMcpServerSsrf promise, so a later SSRF/DNS rejection could surface as an unhandled rejection. Swallow the orphaned promise's settlement in the early-abort branch. * test(mcp): use sleep() instead of raw setTimeout in pinned-fetch test check:utils bans new Promise(resolve => setTimeout(...)) in favor of sleep() from @sim/utils/helpers.
1 parent 6e5356b commit 922515f

2 files changed

Lines changed: 172 additions & 12 deletions

File tree

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

Lines changed: 96 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
/**
22
* @vitest-environment node
33
*/
4+
import { sleep } from '@sim/utils/helpers'
45
import { beforeEach, describe, expect, it, vi } from 'vitest'
56

67
const { mockCreatePinnedFetch, mockValidateMcpServerSsrf, sentinelFetch } = vi.hoisted(() => ({
@@ -32,9 +33,101 @@ describe('createSsrfGuardedMcpFetch', () => {
3233

3334
expect(mockValidateMcpServerSsrf).toHaveBeenCalledWith('https://attacker.example/revoke')
3435
expect(mockCreatePinnedFetch).toHaveBeenCalledWith('203.0.113.10')
35-
expect(sentinelFetch).toHaveBeenCalledWith('https://attacker.example/revoke', {
36-
method: 'POST',
37-
})
36+
expect(sentinelFetch).toHaveBeenCalledWith(
37+
'https://attacker.example/revoke',
38+
expect.objectContaining({ method: 'POST', signal: expect.any(AbortSignal) })
39+
)
40+
})
41+
42+
it('attaches an abort signal to every guarded request even without a caller signal', async () => {
43+
mockValidateMcpServerSsrf.mockResolvedValue('203.0.113.10')
44+
const fetchLike = createSsrfGuardedMcpFetch()
45+
await fetchLike('https://attacker.example/discover')
46+
47+
const [, init] = sentinelFetch.mock.calls[0]
48+
expect(init.signal).toBeInstanceOf(AbortSignal)
49+
})
50+
51+
it('surfaces an McpError when a request exceeds the deadline', async () => {
52+
mockValidateMcpServerSsrf.mockResolvedValue('203.0.113.10')
53+
// Hang until the guard's own deadline aborts the request.
54+
sentinelFetch.mockImplementation(
55+
(_url: string, init: RequestInit) =>
56+
new Promise((_resolve, reject) => {
57+
const signal = init.signal
58+
if (signal?.aborted) {
59+
reject(signal.reason)
60+
return
61+
}
62+
signal?.addEventListener('abort', () => reject(signal.reason), { once: true })
63+
})
64+
)
65+
const fetchLike = createSsrfGuardedMcpFetch(5)
66+
67+
await expect(fetchLike('https://slow.example/token', { method: 'POST' })).rejects.toThrow(
68+
/timed out after 5ms/
69+
)
70+
})
71+
72+
it('bounds a stalled SSRF/DNS validation by the deadline', async () => {
73+
// Validation never resolves (mimics a hanging dns.lookup, which takes no signal).
74+
mockValidateMcpServerSsrf.mockReturnValue(new Promise(() => {}))
75+
const fetchLike = createSsrfGuardedMcpFetch(5)
76+
77+
await expect(fetchLike('https://slow-dns.example/token')).rejects.toThrow(/timed out after 5ms/)
78+
// Never got past validation, so no request was issued.
79+
expect(mockCreatePinnedFetch).not.toHaveBeenCalled()
80+
expect(sentinelFetch).not.toHaveBeenCalled()
81+
})
82+
83+
it('does not orphan the validation promise when the signal is already aborted', async () => {
84+
// Caller aborts before the guard runs, then validation rejects. Without adopting
85+
// the in-flight validation, its rejection would surface as an unhandled rejection.
86+
mockValidateMcpServerSsrf.mockRejectedValue(new Error('blocked late'))
87+
const controller = new AbortController()
88+
controller.abort(new Error('pre-aborted'))
89+
const fetchLike = createSsrfGuardedMcpFetch(60_000)
90+
91+
await expect(
92+
fetchLike('https://slow.example/token', { signal: controller.signal })
93+
).rejects.toThrow('pre-aborted')
94+
expect(mockCreatePinnedFetch).not.toHaveBeenCalled()
95+
// Let the swallowed validation rejection settle so a leak would surface here.
96+
await sleep(0)
97+
})
98+
99+
it('cancels a stalled validation when the caller aborts (not just the deadline)', async () => {
100+
// Validation hangs; the caller's abort — well before the 60s deadline — must settle it.
101+
mockValidateMcpServerSsrf.mockReturnValue(new Promise(() => {}))
102+
const controller = new AbortController()
103+
const fetchLike = createSsrfGuardedMcpFetch(60_000)
104+
const pending = fetchLike('https://slow-dns.example/token', { signal: controller.signal })
105+
controller.abort(new Error('caller cancelled'))
106+
107+
await expect(pending).rejects.toThrow('caller cancelled')
108+
expect(mockCreatePinnedFetch).not.toHaveBeenCalled()
109+
})
110+
111+
it('propagates a caller-initiated abort unchanged (composed with the deadline)', async () => {
112+
mockValidateMcpServerSsrf.mockResolvedValue('203.0.113.10')
113+
sentinelFetch.mockImplementation(
114+
(_url: string, init: RequestInit) =>
115+
new Promise((_resolve, reject) => {
116+
const signal = init.signal
117+
if (signal?.aborted) {
118+
reject(signal.reason)
119+
return
120+
}
121+
signal?.addEventListener('abort', () => reject(signal.reason), { once: true })
122+
})
123+
)
124+
const controller = new AbortController()
125+
// Long deadline so the caller's abort — not the timeout — is what settles the request.
126+
const fetchLike = createSsrfGuardedMcpFetch(60_000)
127+
const pending = fetchLike('https://slow.example/token', { signal: controller.signal })
128+
controller.abort(new Error('caller cancelled'))
129+
130+
await expect(pending).rejects.toThrow('caller cancelled')
38131
})
39132

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

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

Lines changed: 76 additions & 9 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,49 @@ 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+
49+
/**
50+
* Awaits `promise` but rejects with the signal's reason if `signal` aborts first.
51+
* Bounds `dns.lookup`-based SSRF validation (which accepts no signal) by the
52+
* composed deadline + caller signal. Removes the abort listener once `promise`
53+
* settles so a late abort can't surface as an unhandled rejection.
54+
*/
55+
function raceWithSignal<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
56+
if (signal.aborted) {
57+
// The promise is already in flight; adopt its settlement so a later rejection
58+
// (SSRF/DNS failure) can't surface as an unhandled rejection once we've aborted.
59+
promise.catch(() => {})
60+
return Promise.reject(signal.reason)
61+
}
62+
return new Promise<T>((resolve, reject) => {
63+
const onAbort = () => reject(signal.reason)
64+
signal.addEventListener('abort', onAbort, { once: true })
65+
promise.then(
66+
(value) => {
67+
signal.removeEventListener('abort', onAbort)
68+
resolve(value)
69+
},
70+
(error) => {
71+
signal.removeEventListener('abort', onAbort)
72+
reject(error)
73+
}
74+
)
75+
})
76+
}
77+
3478
/**
3579
* Builds a `FetchLike` that validates every outbound request URL against the
3680
* MCP SSRF policy before issuing it, then pins the connection to the resolved
@@ -42,19 +86,42 @@ export function createPinnedMcpFetch(resolvedIP: string): PinnedMcpFetch {
4286
* per request and rejects private/reserved/loopback targets (honoring
4387
* `ALLOWED_MCP_DOMAINS` and self-hosted localhost rules).
4488
*
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).
89+
* Each request is bounded by a `timeoutMs` deadline via `AbortSignal.timeout`,
90+
* composed with any caller-provided signal so cancellation still works. Only our
91+
* own deadline is relabeled to an `McpError`; a caller abort or any other failure
92+
* propagates unchanged.
93+
*
94+
* Both the deadline and any caller-provided signal cover the whole guarded call —
95+
* SSRF validation (whose `dns.lookup` takes no signal, so it's raced against the
96+
* composed signal) and the HTTP request — so a caller awaiting this never waits
97+
* past `timeoutMs` and can cancel at any point, including mid-validation. A
98+
* stalled DNS resolution still runs to completion in the background, but its
99+
* result is discarded.
50100
*
101+
* @param timeoutMs Per-request deadline in ms (defaults to 30s; override for tests).
51102
* @throws McpSsrfError if a request URL resolves to a blocked IP address
103+
* @throws McpError if a request exceeds `timeoutMs`
52104
*/
53-
export function createSsrfGuardedMcpFetch(): FetchLike {
105+
export function createSsrfGuardedMcpFetch(timeoutMs: number = OAUTH_FETCH_TIMEOUT_MS): FetchLike {
54106
return (async (url, init) => {
55107
const target = typeof url === 'string' ? url : url.href
56-
const resolvedIP = await validateMcpServerSsrf(target)
57-
const pinnedFetch: FetchLike = resolvedIP ? createPinnedFetch(resolvedIP) : globalThis.fetch
58-
return pinnedFetch(url, init)
108+
const timeoutSignal = AbortSignal.timeout(timeoutMs)
109+
// Compose deadline + caller signal up front so both phases — SSRF validation
110+
// and the HTTP request — are bounded by the deadline and caller cancellation.
111+
const signal = init?.signal ? AbortSignal.any([init.signal, timeoutSignal]) : timeoutSignal
112+
try {
113+
const resolvedIP = await raceWithSignal(validateMcpServerSsrf(target), signal)
114+
const pinnedFetch: FetchLike = resolvedIP ? createPinnedFetch(resolvedIP) : globalThis.fetch
115+
return await pinnedFetch(url, { ...init, signal })
116+
} catch (error) {
117+
// Relabel only when our own deadline is what fired — identified by the
118+
// rejection reason's identity, not init.signal's state (which may abort
119+
// independently just after the deadline).
120+
if (timeoutSignal.aborted && error === timeoutSignal.reason) {
121+
const host = URL.canParse(target) ? new URL(target).host : target
122+
throw new McpError(`MCP authorization request to ${host} timed out after ${timeoutMs}ms`)
123+
}
124+
throw error
125+
}
59126
}) satisfies FetchLike
60127
}

0 commit comments

Comments
 (0)