Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 3.8k
fix(mcp): bound OAuth discovery/DCR/token fetches with a timeout#5776
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Uh oh!
There was an error while loading. Please reload this page.
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
4fa5fde
fix(mcp): bound OAuth discovery/DCR/token fetches with a timeout
waleedlatif1 aa42acb
fix(mcp): bound SSRF/DNS validation by the deadline too
waleedlatif1 ea33016
fix(mcp): compose caller signal before validation + attribute timeout…
waleedlatif1 071d0cc
fix(mcp): adopt in-flight validation on early abort to avoid unhandle…
waleedlatif1 1a8be6f
test(mcp): use sleep() instead of raw setTimeout in pinned-fetch test
waleedlatif1 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Jump to file
Failed to load files.
Loading
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -4,6 +4,7 @@ import { | ||
| createPinnedFetchWithDispatcher, | ||
| } from '@/lib/core/security/input-validation.server' | ||
| import { validateMcpServerSsrf } from '@/lib/mcp/domain-check' | ||
| import { McpError } from '@/lib/mcp/types' | ||
| /** Pinned fetch for the live MCP transport, plus a handle to release its sockets. */ | ||
| export interface PinnedMcpFetch { | ||
| @@ -31,6 +32,49 @@ export function createPinnedMcpFetch(resolvedIP: string): PinnedMcpFetch { | ||
| return { fetch: pinnedFetch, close: () => dispatcher.destroy() } | ||
| } | ||
| /** | ||
| * Per-request deadline for guarded MCP OAuth / RFC 7009 revocation HTTP calls. | ||
| * | ||
| * The MCP SDK issues OAuth discovery, dynamic client registration, and token | ||
| * exchange with a bare `fetch` and no `AbortSignal` — only the JSON-RPC message | ||
| * layer gets the SDK's request timeout. Combined with undici's 5-minute default | ||
| * headers/body timeouts, a slow or unresponsive authorization server leaves the | ||
| * request (and the browser the user is waiting on during `/oauth/start`) pending | ||
| * for minutes. Bounding each leg turns that into a fast, actionable failure. 30s | ||
| * mirrors `MCP_CLIENT_CONSTANTS.DEFAULT_CONNECTION_TIMEOUT` and leaves wide | ||
| * headroom over a healthy server, which completes each leg in well under a second. | ||
| */ | ||
| const OAUTH_FETCH_TIMEOUT_MS = 30_000 | ||
| /** | ||
| * Awaits `promise` but rejects with the signal's reason if `signal` aborts first. | ||
| * Bounds `dns.lookup`-based SSRF validation (which accepts no signal) by the | ||
| * composed deadline + caller signal. Removes the abort listener once `promise` | ||
| * settles so a late abort can't surface as an unhandled rejection. | ||
| */ | ||
| function raceWithSignal<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> { | ||
| if (signal.aborted) { | ||
| // The promise is already in flight; adopt its settlement so a later rejection | ||
| // (SSRF/DNS failure) can't surface as an unhandled rejection once we've aborted. | ||
| promise.catch(() => {}) | ||
| return Promise.reject(signal.reason) | ||
| } | ||
| return new Promise<T>((resolve, reject) => { | ||
| const onAbort = () => reject(signal.reason) | ||
| signal.addEventListener('abort', onAbort, { once: true }) | ||
| promise.then( | ||
| (value) => { | ||
| signal.removeEventListener('abort', onAbort) | ||
| resolve(value) | ||
| }, | ||
| (error) => { | ||
| signal.removeEventListener('abort', onAbort) | ||
| reject(error) | ||
| } | ||
| ) | ||
| }) | ||
| } | ||
| /** | ||
| * Builds a `FetchLike` that validates every outbound request URL against the | ||
| * MCP SSRF policy before issuing it, then pins the connection to the resolved | ||
| @@ -42,19 +86,42 @@ export function createPinnedMcpFetch(resolvedIP: string): PinnedMcpFetch { | ||
| * per request and rejects private/reserved/loopback targets (honoring | ||
| * `ALLOWED_MCP_DOMAINS` and self-hosted localhost rules). | ||
| * | ||
| * Note: a caller-provided `AbortSignal` in `init` only bounds the HTTP request, | ||
| * not the validation DNS lookup — Node's `dns.lookup` does not accept a signal, | ||
| * so a hanging resolution can extend the overall call past the caller's timeout | ||
| * by up to the OS DNS timeout. Acceptable here because all consumers are | ||
| * best-effort, non-blocking flows (OAuth discovery and RFC 7009 revocation). | ||
| * Each request is bounded by a `timeoutMs` deadline via `AbortSignal.timeout`, | ||
| * composed with any caller-provided signal so cancellation still works. Only our | ||
| * own deadline is relabeled to an `McpError`; a caller abort or any other failure | ||
| * propagates unchanged. | ||
| * | ||
| * Both the deadline and any caller-provided signal cover the whole guarded call — | ||
| * SSRF validation (whose `dns.lookup` takes no signal, so it's raced against the | ||
| * composed signal) and the HTTP request — so a caller awaiting this never waits | ||
| * past `timeoutMs` and can cancel at any point, including mid-validation. A | ||
| * stalled DNS resolution still runs to completion in the background, but its | ||
| * result is discarded. | ||
| * | ||
| * @param timeoutMs Per-request deadline in ms (defaults to 30s; override for tests). | ||
| * @throws McpSsrfError if a request URL resolves to a blocked IP address | ||
| * @throws McpError if a request exceeds `timeoutMs` | ||
| */ | ||
| export function createSsrfGuardedMcpFetch(): FetchLike { | ||
| export function createSsrfGuardedMcpFetch(timeoutMs: number = OAUTH_FETCH_TIMEOUT_MS): FetchLike { | ||
| return (async (url, init) => { | ||
| const target = typeof url === 'string' ? url : url.href | ||
| const resolvedIP = await validateMcpServerSsrf(target) | ||
| const pinnedFetch: FetchLike = resolvedIP ? createPinnedFetch(resolvedIP) : globalThis.fetch | ||
| return pinnedFetch(url, init) | ||
| const timeoutSignal = AbortSignal.timeout(timeoutMs) | ||
| // Compose deadline + caller signal up front so both phases — SSRF validation | ||
| // and the HTTP request — are bounded by the deadline and caller cancellation. | ||
| const signal = init?.signal ? AbortSignal.any([init.signal, timeoutSignal]) : timeoutSignal | ||
| try { | ||
| const resolvedIP = await raceWithSignal(validateMcpServerSsrf(target), signal) | ||
| const pinnedFetch: FetchLike = resolvedIP ? createPinnedFetch(resolvedIP) : globalThis.fetch | ||
| return await pinnedFetch(url, { ...init, signal }) | ||
waleedlatif1 marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| } catch (error) { | ||
| // Relabel only when our own deadline is what fired — identified by the | ||
| // rejection reason's identity, not init.signal's state (which may abort | ||
| // independently just after the deadline). | ||
| if (timeoutSignal.aborted && error === timeoutSignal.reason) { | ||
| const host = URL.canParse(target) ? new URL(target).host : target | ||
| throw new McpError(`MCP authorization request to ${host} timed out after ${timeoutMs}ms`) | ||
| } | ||
| throw error | ||
waleedlatif1 marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| } | ||
| }) satisfies FetchLike | ||
| } | ||
Oops, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.