fix: validate authorizationParams off disk; note the EMA exception (#2018)

Copilot round 5 — three of four findings implemented.

- `cleanAuthorizationParams` validates the field as it comes off disk. Only the
  web client's /api/servers route checked the shape; the CLI and TUI read
  mcp.json directly, so `authorizationParams: "oops"` enumerated as the
  character-indexed pairs 0=o, 1=o, … and `{ audience: 5 }` reached
  URLSearchParams.set, which stringifies it — either way the Inspector sent
  query parameters the user never wrote. Now validated in shared code so all
  three clients enforce the documented string→string contract, warning on each
  rejection. Follows `cleanRoots`, which guards the same class for `roots`.

- The form's description now states that these parameters are not sent while
  Enterprise-managed authorization is on. The EMA leg authorizes against the
  enterprise IdP — a different authorization server — and deliberately sends
  none of them, so the copy was presenting a configured-but-inert value as
  active.

- The /api/servers malformed-`oauth` warning listed an expected shape omitting
  `onInsufficientScope`, which the validator accepts and the docs describe, so
  it told users a valid config was malformed.

Ten new cases, all verified to fail against the pre-fix sources.

Declined: recording the authorize URL in the Network tab (repeat of round 2) —
that URL is reached by a top-level browser navigation rather than a tracked
fetch, so it has never appeared there for any authorization request; adding a
synthetic entry changes every OAuth flow and is a product decision for a
maintainer, not a change to make inside this PR.

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-15 22:10:36 -04:00
commit e6e4600a73
2 changed files with 173 additions and 9 deletions

View file

@ -1255,7 +1255,7 @@ export function createRemoteApp(
if ("oauth" in valObj && !isOauthObject(valObj.oauth)) {
logWarn(
{ route: "/api/servers", id, droppedKey: "oauth" },
"Dropping malformed `oauth` field — expected `{ clientId?, clientSecret?, scopes?, authorizationParams?, enterpriseManaged? }`.",
"Dropping malformed `oauth` field — expected `{ clientId?, clientSecret?, scopes?, authorizationParams?, enterpriseManaged?, onInsufficientScope? }`.",
);
delete valObj.oauth;
}

View file

@ -194,6 +194,45 @@ export function envPairsToRecord(
return out;
}
/**
* Validate a server's `oauth.authorizationParams` as it comes off disk, or
* `undefined` when there is nothing usable. (#2018)
*
* `StoredMCPServer` types this as `Record<string, string>`, but that is a
* compile-time promise a hand-edited `mcp.json` does not keep — and only the web
* client's `/api/servers` route checks it, while the CLI and TUI read the file
* directly. Without this, `authorizationParams: "oops"` enumerates as the
* character-indexed pairs `0=o, 1=o, …` and `{ audience: 5 }` reaches
* `URLSearchParams.set`, which stringifies it — either way the Inspector sends
* query parameters the user never wrote. Each rejection warns, following
* `cleanRoots` above, which guards the same class for `roots`.
*/
export function cleanAuthorizationParams(
params: Record<string, string> | undefined,
): Record<string, string> | undefined {
if (params === undefined) return undefined;
// Keep: unreachable per the parameter type, reachable from disk.
if (typeof params !== "object" || params === null || Array.isArray(params)) {
console.warn(
"Ignoring `oauth.authorizationParams`: expected an object of string values, got",
Array.isArray(params) ? "array" : typeof params,
);
return undefined;
}
const out: Record<string, string> = {};
for (const [key, value] of Object.entries(params)) {
if (typeof value !== "string") {
console.warn(
`Dropping \`oauth.authorizationParams.${key}\`: expected a string value, got ${typeof value}.`,
);
continue;
}
if (key.trim() === "") continue;
out[key] = value;
}
return Object.keys(out).length > 0 ? out : undefined;
}
/**
* Collapse a server's custom authorization-parameter rows into the record the
* `InspectorClientOptions.oauth.authorizationParams` option takes, or
@ -309,14 +348,14 @@ export function storedFieldsToInspectorSettings(
if (stored.oauth?.scopes) settings.oauthScopes = stored.oauth.scopes;
// Optional record with no default; carried through only when the file has a
// non-empty object, so an absent/empty field reads back as unset and the
// write side then omits it — keeping a byte-stable round-trip. (#2018)
if (
stored.oauth?.authorizationParams &&
Object.keys(stored.oauth.authorizationParams).length > 0
) {
settings.oauthAuthorizationParams = envRecordToPairs(
stored.oauth.authorizationParams,
);
// write side then omits it — keeping a byte-stable round-trip. Sanitized
// rather than trusted: only the web client's `/api/servers` route validates
// this field, while the CLI/TUI read `mcp.json` straight off disk. (#2018)
const authorizationParams = cleanAuthorizationParams(
stored.oauth?.authorizationParams,
);
if (authorizationParams) {
settings.oauthAuthorizationParams = envRecordToPairs(authorizationParams);
}
if (stored.oauth?.onInsufficientScope) {
settings.oauthOnInsufficientScope = stored.oauth.onInsufficientScope;