perf(auth): gate the override wrapper on the discovery request, and guard disk values

Two suppressed review findings.

With an override configured, the wrapper cloned and parsed every
successful application/json response — every tools/call result and every
resource payload on the direct CLI/TUI transports — only to conclude it
was not a metadata document. It now tests the request URL for the RFC
8414 / OIDC well-known paths first, so ordinary traffic costs one string
test and is returned untouched.

The read path also trusted StoredMCPServer's `string` type for the two
override fields, which a hand-edited mcp.json does not honor: the CLI and
TUI read the file directly and then `.trim()` these, so a number would
crash the server load. Guarded with cleanEndpointOverride, mirroring
cleanAuthorizationParams.

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-16 22:11:16 -04:00
commit 8d6803953c
2 changed files with 162 additions and 18 deletions

View file

@ -196,6 +196,51 @@ export function applyOAuthEndpointOverrides(
return patched;
}
/**
* The well-known paths authorization-server metadata is discovered at: RFC 8414
* (§3.1, including the path-suffixed and path-prefixed variants the SDK's
* `buildDiscoveryUrls` emits) and OpenID Connect Discovery. Matched as a
* substring of the pathname so every variant is covered by two literals.
*/
const AS_METADATA_WELL_KNOWN_PATHS = [
"/.well-known/oauth-authorization-server",
"/.well-known/openid-configuration",
] as const;
/** The request URL a `fetch` call was made with, in any of its three forms. */
function requestUrlOf(input: RequestInfo | URL): string {
if (typeof input === "string") return input;
if (input instanceof URL) return input.href;
return input.url;
}
/**
* Whether a request is an authorization-server metadata discovery request.
*
* This is what keeps the wrapper off the hot path. Without it, every successful
* `application/json` response on the connection — every `tools/call` result, and
* every resource payload on the direct CLI/TUI transports — would be buffered
* and parsed a second time before the SDK could consume it, purely to discover
* that it is not a metadata document. Gating on the discovery URL first means
* ordinary traffic is untouched: one string test and the response is returned.
*
* A URL that will not parse falls back to testing the raw string, which is the
* conservative direction — worst case a request is *considered*, and the
* metadata predicate then rejects it.
*/
function isAuthorizationServerMetadataRequest(
input: RequestInfo | URL,
): boolean {
const raw = requestUrlOf(input);
let pathname: string;
try {
pathname = new URL(raw).pathname;
} catch {
pathname = raw;
}
return AS_METADATA_WELL_KNOWN_PATHS.some((path) => pathname.includes(path));
}
/**
* Whether a `content-type` names a **whole JSON document** — something that can
* be read to completion.
@ -268,6 +313,9 @@ export function withOAuthEndpointOverrides(
const response = await fetchFn(input, init);
const overrides = resolveOverrides();
if (!overrides) return response;
// Cheapest tests first: only a discovery request can carry the document
// this wrapper rewrites, so ordinary traffic never reaches the clone below.
if (!isAuthorizationServerMetadataRequest(input)) return response;
if (!response.ok) return response;
if (!isJsonDocumentResponse(response.headers.get("content-type")))
return response;

View file

@ -248,6 +248,33 @@ export function oauthAuthorizationParamsFromSettings(
return Object.keys(record).length > 0 ? record : undefined;
}
/**
* Validate one of a server's `oauth` endpoint overrides as it comes off disk,
* or `undefined` when there is nothing usable. (#1906)
*
* Same reasoning as `cleanAuthorizationParams` above: `StoredMCPServer` types
* these as `string`, but that is a compile-time promise a hand-edited file does
* not keep, and the CLI/TUI read `mcp.json` directly. A non-string reaches
* `oauthEndpointOverridesFromSettings`, which calls `.trim()` on it — so without
* this the whole server list fails to load. URL *validity* is not checked here;
* that happens where the override is applied, so a typo drops one field with a
* warning instead of anything louder.
*/
export function cleanEndpointOverride(
value: string | undefined,
field: "authorizationUrl" | "tokenUrl",
): string | undefined {
if (value === undefined) return undefined;
// Keep: unreachable per the parameter type, reachable from disk.
if (typeof value !== "string") {
console.warn(
`Ignoring \`oauth.${field}\`: expected a string, got ${typeof value}.`,
);
return undefined;
}
return value.trim() || undefined;
}
/**
* Collapse a server's endpoint-override fields into the
* `InspectorClientOptions.oauth` sub-shape, or `undefined` when neither is set.
@ -381,10 +408,20 @@ export function storedFieldsToInspectorSettings(
settings.oauthAuthorizationParams = envRecordToPairs(authorizationParams);
}
// Truthiness drops empty strings like the credential fields above, so a
// cleared field reads back as unset rather than as an empty override. (#1906)
if (stored.oauth?.authorizationUrl)
settings.oauthAuthorizationUrl = stored.oauth.authorizationUrl;
if (stored.oauth?.tokenUrl) settings.oauthTokenUrl = stored.oauth.tokenUrl;
// cleared field reads back as unset rather than as an empty override. The
// `string` type is a compile-time promise a hand-edited `mcp.json` does not
// keep, and only the web client's `/api/servers` route checks it — the CLI and
// TUI read the file directly and then call `.trim()` on these in
// `oauthEndpointOverridesFromSettings`, so a non-string would crash the server
// load. Sanitized like `authorizationParams` above rather than trusted.
// (#1906)
const authorizationUrl = cleanEndpointOverride(
stored.oauth?.authorizationUrl,
"authorizationUrl",
);
if (authorizationUrl) settings.oauthAuthorizationUrl = authorizationUrl;
const tokenUrl = cleanEndpointOverride(stored.oauth?.tokenUrl, "tokenUrl");
if (tokenUrl) settings.oauthTokenUrl = tokenUrl;
if (stored.oauth?.onInsufficientScope) {
settings.oauthOnInsufficientScope = stored.oauth.onInsufficientScope;
}