diff --git a/clients/web/src/components/groups/NetworkEntry/NetworkEntry.stories.tsx b/clients/web/src/components/groups/NetworkEntry/NetworkEntry.stories.tsx index e056c23b2..6d9f64df5 100644 --- a/clients/web/src/components/groups/NetworkEntry/NetworkEntry.stories.tsx +++ b/clients/web/src/components/groups/NetworkEntry/NetworkEntry.stories.tsx @@ -1,4 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, userEvent, within } from "storybook/test"; import type { FetchRequestEntry } from "../../../../../../core/mcp/types.js"; import { NetworkEntry } from "./NetworkEntry"; @@ -38,7 +39,13 @@ const authEntry: FetchRequestEntry = { responseStatus: 200, responseStatusText: "OK", responseHeaders: { "content-type": "application/json" }, - responseBody: '{"access_token":"x","token_type":"bearer"}', + responseBody: JSON.stringify({ + access_token: "eyJhbGciOiJSUzI1NiwidHlwIjoiSldUIn0.payload.sig", + token_type: "Bearer", + expires_in: 3600, + refresh_token: "rt_9f8e7d6c5b4a", + scope: "mcp:tools", + }), duration: 120, category: "auth", }; @@ -99,6 +106,30 @@ export const TransportSuccessExpanded: Story = { export const AuthSuccess: Story = { args: { entry: authEntry, isListExpanded: true }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + // Both bodies carry secrets: the form request (`code=…`) and the JSON + // response (`access_token`). Both are masked by default — the raw values + // must not be visible until explicitly revealed. + const hidden = canvas.getAllByText("Secrets hidden"); + await expect(hidden.length).toBeGreaterThanOrEqual(2); + await expect(canvasElement.textContent).not.toContain("eyJhbGciOiJSUzI1"); + await expect(canvasElement.textContent).toContain("••••••••"); + // Non-secret fields stay visible. + await expect(canvasElement.textContent).toContain("Bearer"); + + // Reveal every masked body and confirm the raw response token appears. + const revealButtons = canvas.getAllByRole("button", { + name: "Reveal secrets in body", + }); + for (const button of revealButtons) { + await userEvent.click(button); + } + await expect( + canvas.getAllByText("Secrets revealed").length, + ).toBeGreaterThanOrEqual(revealButtons.length); + await expect(canvasElement.textContent).toContain("eyJhbGciOiJSUzI1"); + }, }; export const HttpError: Story = { diff --git a/clients/web/src/components/groups/NetworkEntry/NetworkEntry.test.tsx b/clients/web/src/components/groups/NetworkEntry/NetworkEntry.test.tsx index 6dc1381a2..514e78e80 100644 --- a/clients/web/src/components/groups/NetworkEntry/NetworkEntry.test.tsx +++ b/clients/web/src/components/groups/NetworkEntry/NetworkEntry.test.tsx @@ -196,4 +196,79 @@ describe("NetworkEntry", () => { await user.click(screen.getByRole("button", { name: "Expand" })); expect(screen.getByText(/Body too large to preview/)).toBeInTheDocument(); }); + + it("masks token-response secrets until revealed, then shows the raw value", async () => { + const user = userEvent.setup(); + const authEntry: FetchRequestEntry = { + ...baseEntry, + category: "auth", + url: "http://localhost:3001/token", + requestBody: undefined, + responseBody: JSON.stringify({ + access_token: "super-secret-token", + token_type: "Bearer", + }), + }; + const { container } = renderWithMantine( + , + ); + // Masked by default: the reveal affordance is present and the raw secret + // is nowhere in the DOM, but non-secret fields still render. + expect(screen.getByText("Secrets hidden")).toBeInTheDocument(); + expect(container.textContent).not.toContain("super-secret-token"); + expect(container.textContent).toContain("••••••••"); + expect(container.textContent).toContain("Bearer"); + + await user.click( + screen.getByRole("button", { name: "Reveal secrets in body" }), + ); + + expect(screen.getByText("Secrets revealed")).toBeInTheDocument(); + expect(container.textContent).toContain("super-secret-token"); + + // Toggling back re-masks. + await user.click( + screen.getByRole("button", { name: "Hide secrets in body" }), + ); + expect(container.textContent).not.toContain("super-secret-token"); + }); + + it("masks a form-encoded request body (code/verifier) until revealed", async () => { + const user = userEvent.setup(); + const authEntry: FetchRequestEntry = { + ...baseEntry, + category: "auth", + url: "http://localhost:3001/token", + requestHeaders: { "content-type": "application/x-www-form-urlencoded" }, + requestBody: + "grant_type=authorization_code&code=SECRETCODE&code_verifier=SECRETVERIFIER", + responseStatus: undefined, + responseStatusText: undefined, + responseHeaders: undefined, + responseBody: undefined, + }; + const { container } = renderWithMantine( + , + ); + expect(screen.getByText("Secrets hidden")).toBeInTheDocument(); + expect(container.textContent).not.toContain("SECRETCODE"); + expect(container.textContent).not.toContain("SECRETVERIFIER"); + expect(container.textContent).toContain("••••••••"); + // Non-secret param stays visible. + expect(container.textContent).toContain("grant_type=authorization_code"); + + await user.click( + screen.getByRole("button", { name: "Reveal secrets in body" }), + ); + expect(container.textContent).toContain("SECRETCODE"); + expect(container.textContent).toContain("SECRETVERIFIER"); + }); + + it("does not add a reveal toggle for non-secret bodies", () => { + renderWithMantine(); + expect(screen.queryByText("Secrets hidden")).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Reveal secrets in body" }), + ).not.toBeInTheDocument(); + }); }); diff --git a/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx b/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx index b7040832d..7cf8da595 100644 --- a/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx +++ b/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { Badge, Button, @@ -13,6 +13,7 @@ import { import type { FetchRequestEntry } from "@inspector/core/mcp/types.js"; import { isLongLivedStreamResponse } from "@inspector/core/mcp/fetchTracking.js"; import { ContentViewer } from "../../elements/ContentViewer/ContentViewer"; +import { maskSecretsInBody } from "../../../utils/maskSecrets"; export interface NetworkEntryProps { entry: FetchRequestEntry; @@ -126,8 +127,47 @@ function HeadersTable({ headers }: { headers: Record }) { ); } -function BodyPreview({ body }: { body: string }) { +const RevealButton = Button.withProps({ + variant: "subtle", + size: "compact-xs", +}); + +function BodyPreview({ + body, + contentType, +}: { + body: string; + contentType?: string; +}) { + // Reveal state for masked secrets. Hooks run before any early return so the + // order stays stable across the too-large / has-secrets branches. The reveal + // state resets when the body or its content-type changes because callers key + // `` by both (remounting on swap), so a previously-revealed view + // never persists across a content (or masking) change. + const [revealed, setRevealed] = useState(false); + const tooLarge = body.length > MAX_INLINE_BODY_CHARS; + + // OAuth responses (token exchange, DCR) and the token request carry + // bearer-grade secrets. Mask them by default and gate the raw values behind + // an explicit reveal so they aren't exposed at a glance during a + // screen-share. The entry's content-type scopes which parser runs (so a + // plaintext/HTML error body is never guessed at). Bodies without secrets + // render as-is with no toggle. + // + // Memoized so a Reveal/Hide click (a re-render) doesn't re-parse and re-walk + // the body; the cost is paid once per mount, and the `key={…}` remount on + // body/content-type change re-runs it. Skipped for too-large bodies so we + // never parse something we won't display (the hook must run unconditionally, + // hence the in-memo guard rather than an early return above it). + const { masked, hasSecrets } = useMemo( + () => + tooLarge + ? { masked: body, hasSecrets: false } + : maskSecretsInBody(body, contentType), + [tooLarge, body, contentType], + ); + if (tooLarge) { return ( @@ -135,7 +175,30 @@ function BodyPreview({ body }: { body: string }) { ); } - return ; + + if (!hasSecrets) { + return ; + } + + const shown = revealed ? body : masked; + return ( + + + + {revealed ? "Secrets revealed" : "Secrets hidden"} + + setRevealed((v) => !v)} + aria-label={ + revealed ? "Hide secrets in body" : "Reveal secrets in body" + } + > + {revealed ? "Hide" : "Reveal"} + + + + + ); } export function NetworkEntry({ entry, isListExpanded }: NetworkEntryProps) { @@ -192,7 +255,11 @@ export function NetworkEntry({ entry, isListExpanded }: NetworkEntryProps) { Request Body - + )} {entry.responseHeaders && ( @@ -209,7 +276,11 @@ export function NetworkEntry({ entry, isListExpanded }: NetworkEntryProps) { Response Body {entry.responseBody ? ( - + ) : ( {isLongLivedStream(entry) diff --git a/clients/web/src/utils/maskSecrets.test.ts b/clients/web/src/utils/maskSecrets.test.ts new file mode 100644 index 000000000..39c8dac15 --- /dev/null +++ b/clients/web/src/utils/maskSecrets.test.ts @@ -0,0 +1,205 @@ +import { describe, it, expect } from "vitest"; +import { maskSecretsInBody, MASK_PLACEHOLDER } from "./maskSecrets"; + +describe("maskSecretsInBody", () => { + it("masks token fields in a token-exchange response and flags secrets", () => { + const body = JSON.stringify({ + access_token: "abc.def.ghi", + token_type: "Bearer", + expires_in: 3600, + refresh_token: "r3fr3sh", + scope: "mcp:tools", + }); + const { masked, hasSecrets } = maskSecretsInBody(body); + expect(hasSecrets).toBe(true); + const parsed = JSON.parse(masked); + expect(parsed.access_token).toBe(MASK_PLACEHOLDER); + expect(parsed.refresh_token).toBe(MASK_PLACEHOLDER); + // Non-secret fields pass through untouched. + expect(parsed.token_type).toBe("Bearer"); + expect(parsed.expires_in).toBe(3600); + expect(parsed.scope).toBe("mcp:tools"); + // The raw secret never appears in the masked output. + expect(masked).not.toContain("abc.def.ghi"); + expect(masked).not.toContain("r3fr3sh"); + }); + + it("masks id_token and client_secret (case-insensitive keys)", () => { + const body = JSON.stringify({ + ID_Token: "jwt", + Client_Secret: "shhh", + client_id: "public-123", + }); + const { masked, hasSecrets } = maskSecretsInBody(body); + expect(hasSecrets).toBe(true); + const parsed = JSON.parse(masked); + expect(parsed.ID_Token).toBe(MASK_PLACEHOLDER); + expect(parsed.Client_Secret).toBe(MASK_PLACEHOLDER); + // client_id is not a secret. + expect(parsed.client_id).toBe("public-123"); + }); + + it("masks secrets nested in objects and arrays", () => { + const body = JSON.stringify({ + data: { tokens: [{ access_token: "deep" }] }, + }); + const { masked, hasSecrets } = maskSecretsInBody(body); + expect(hasSecrets).toBe(true); + expect(JSON.parse(masked).data.tokens[0].access_token).toBe( + MASK_PLACEHOLDER, + ); + }); + + it("masks a non-string value under a sensitive key wholesale (no leak via recursion)", () => { + // A non-standard server wrapping the token in an object: the value must be + // replaced wholesale rather than recursed into (where the inner `value` + // key isn't sensitive and would otherwise leak). + const body = JSON.stringify({ + access_token: { value: "leaky-secret", expires_in: 3600 }, + }); + const { masked, hasSecrets } = maskSecretsInBody(body); + expect(hasSecrets).toBe(true); + expect(masked).not.toContain("leaky-secret"); + expect(JSON.parse(masked).access_token).toBe(MASK_PLACEHOLDER); + }); + + it("masks a confidential-client DCR (/register) response", () => { + // RFC 7591/7592 happy path: client_secret and the registration management + // token are bearer-grade and masked; client_id and metadata stay visible. + const body = JSON.stringify({ + client_id: "dyn-123", + client_secret: "REG-SECRET", + registration_access_token: "REG-RAT", + registration_client_uri: "http://localhost:3001/register/dyn-123", + client_id_issued_at: 1735689600, + }); + const { masked, hasSecrets } = maskSecretsInBody(body); + expect(hasSecrets).toBe(true); + const parsed = JSON.parse(masked); + expect(parsed.client_secret).toBe(MASK_PLACEHOLDER); + expect(parsed.registration_access_token).toBe(MASK_PLACEHOLDER); + expect(masked).not.toContain("REG-SECRET"); + expect(masked).not.toContain("REG-RAT"); + // Non-secret fields untouched. + expect(parsed.client_id).toBe("dyn-123"); + expect(parsed.registration_client_uri).toBe( + "http://localhost:3001/register/dyn-123", + ); + }); + + it("reports no secrets (and leaves content intact) for discovery metadata", () => { + const body = JSON.stringify({ + issuer: "http://localhost:3001/", + authorization_endpoint: "http://localhost:3001/authorize", + scopes_supported: ["mcp:tools"], + }); + const { masked, hasSecrets } = maskSecretsInBody(body); + expect(hasSecrets).toBe(false); + expect(JSON.parse(masked).authorization_endpoint).toBe( + "http://localhost:3001/authorize", + ); + }); + + it("does not flag an empty-string secret value", () => { + const { hasSecrets } = maskSecretsInBody( + JSON.stringify({ access_token: "" }), + ); + expect(hasSecrets).toBe(false); + }); + + it("returns a form body with no sensitive params unchanged", () => { + const body = "grant_type=authorization_code&redirect_uri=http://x/cb"; + const { masked, hasSecrets } = maskSecretsInBody(body); + expect(hasSecrets).toBe(false); + expect(masked).toBe(body); + }); + + it("masks sensitive params in a form-encoded token request, preserving other params", () => { + const body = + "grant_type=authorization_code&code=AUTHCODE&code_verifier=VERIFIER&client_id=public-1&redirect_uri=http://x/cb"; + const { masked, hasSecrets } = maskSecretsInBody(body); + expect(hasSecrets).toBe(true); + expect(masked).toContain(`code=${MASK_PLACEHOLDER}`); + expect(masked).toContain(`code_verifier=${MASK_PLACEHOLDER}`); + expect(masked).not.toContain("AUTHCODE"); + expect(masked).not.toContain("VERIFIER"); + // Non-secret params are untouched (and formatting preserved). + expect(masked).toContain("grant_type=authorization_code"); + expect(masked).toContain("client_id=public-1"); + expect(masked).toContain("redirect_uri=http://x/cb"); + }); + + it("masks refresh_token and client_secret in a form-encoded refresh request", () => { + const body = + "grant_type=refresh_token&refresh_token=RTVAL&client_secret=CSVAL"; + const { masked, hasSecrets } = maskSecretsInBody(body); + expect(hasSecrets).toBe(true); + expect(masked).not.toContain("RTVAL"); + expect(masked).not.toContain("CSVAL"); + }); + + it("does NOT mask a JSON `code` field (e.g. a JSON-RPC error code)", () => { + // `code` is form-only sensitive; in JSON it's usually an error/status code. + const { masked, hasSecrets } = maskSecretsInBody( + JSON.stringify({ code: "some-string-code", message: "boom" }), + ); + expect(hasSecrets).toBe(false); + expect(JSON.parse(masked).code).toBe("some-string-code"); + }); + + it("does not treat pure reformatting as masking", () => { + // Minified JSON with no secret keys → reserialized but hasSecrets false. + const { hasSecrets } = maskSecretsInBody('{"a":1,"b":[2,3]}'); + expect(hasSecrets).toBe(false); + }); + + it("passes through valid-but-non-object JSON (string / number / null) untouched", () => { + for (const raw of ['"abc"', "42", "null", "true"]) { + const { masked, hasSecrets } = maskSecretsInBody(raw); + expect(hasSecrets).toBe(false); + expect(JSON.parse(masked)).toEqual(JSON.parse(raw)); + } + }); + + it("masks every occurrence of a repeated sensitive form param", () => { + const { masked, hasSecrets } = maskSecretsInBody("code=AAA&code=BBB"); + expect(hasSecrets).toBe(true); + expect(masked).toBe(`code=${MASK_PLACEHOLDER}&code=${MASK_PLACEHOLDER}`); + }); + + it("does not flag an empty form param value", () => { + const { masked, hasSecrets } = maskSecretsInBody( + "grant_type=refresh_token&client_secret=", + ); + expect(hasSecrets).toBe(false); + expect(masked).toBe("grant_type=refresh_token&client_secret="); + }); + + it("honors an explicit content-type: form params under a JSON type are not form-masked", () => { + // `code` is form-only sensitive; with a JSON content-type the body goes + // through the JSON parser (fails to parse → untouched), not form masking. + const { hasSecrets } = maskSecretsInBody( + "code=AAA&code_verifier=BBB", + "application/json", + ); + expect(hasSecrets).toBe(false); + }); + + it("skips masking for a known non-JSON, non-form content-type", () => { + // A plaintext/HTML error body that happens to contain `access_token=…` + // must not be guessed at as form-encoded when the content-type says text. + const body = "Error: access_token=leaked has expired"; + const { masked, hasSecrets } = maskSecretsInBody(body, "text/plain"); + expect(hasSecrets).toBe(false); + expect(masked).toBe(body); + }); + + it("uses the form parser for an explicit form content-type", () => { + const { masked, hasSecrets } = maskSecretsInBody( + "refresh_token=RT", + "application/x-www-form-urlencoded", + ); + expect(hasSecrets).toBe(true); + expect(masked).toBe(`refresh_token=${MASK_PLACEHOLDER}`); + }); +}); diff --git a/clients/web/src/utils/maskSecrets.ts b/clients/web/src/utils/maskSecrets.ts new file mode 100644 index 000000000..3fd4d9a5d --- /dev/null +++ b/clients/web/src/utils/maskSecrets.ts @@ -0,0 +1,181 @@ +/** + * Masks sensitive OAuth values inside a captured HTTP body for display in the + * Network tab. OAuth token-exchange / registration responses carry credentials + * (`access_token`, `refresh_token`, …) and the token *request* (a + * `application/x-www-form-urlencoded` body) carries `code` / `code_verifier` / + * `client_secret`. We show the body so it's inspectable, but mask those values + * by default so they aren't exposed at a glance during a screen-share. The raw + * body is preserved by the caller and shown only when the user reveals it. + * + * Content-type selects the parser: `*json*` → JSON masking, form-urlencoded → + * form masking, any other known type → no masking. When the content-type is + * absent/unknown the body is sniffed (parse as JSON first, else treat as + * form). See `maskSecretsInBody`. + */ + +// Keys masked in JSON bodies — bearer-grade secrets only. `code` is +// deliberately NOT here: a JSON body's `code` is usually something else (e.g. +// a JSON-RPC error `code`), and we don't want to mask those. +// `registration_access_token` is the DCR management credential (RFC 7592), +// same bearer class as `access_token`. +const JSON_SENSITIVE_KEYS = new Set([ + "access_token", + "refresh_token", + "id_token", + "client_secret", + "registration_access_token", +]); + +// Keys masked in form-encoded bodies — the JSON set plus the single-use OAuth +// request material that only appears as form params (authorization code, PKCE +// verifier, private-key-JWT client assertion). +const FORM_SENSITIVE_KEYS = new Set([ + ...JSON_SENSITIVE_KEYS, + "code", + "code_verifier", + "client_assertion", +]); + +// What a masked value is replaced with. A fixed-width dotted string keeps the +// shape recognizable as "a value was here" without hinting at its length. +export const MASK_PLACEHOLDER = "••••••••"; + +function isSensitiveKey(set: ReadonlySet, key: string): boolean { + return set.has(key.toLowerCase()); +} + +// Whether a value under a sensitive key should be masked. The contract is +// "any non-null, non-empty-string value": strings are masked when non-empty +// (an empty `access_token` carries nothing), and any non-string value +// (object/array/number/boolean wrapper — pathological for OAuth, but a safe +// default) is masked wholesale so it can't leak through the recursion. +function isMaskableValue(value: unknown): boolean { + if (value === null || value === undefined) return false; + if (typeof value === "string") return value.length > 0; + return true; +} + +interface MaskedNode { + node: unknown; + masked: boolean; +} + +// Recursively mask sensitive values in a parsed JSON node, tracking whether +// anything was masked (so the caller never has to infer it by comparing +// serializations — reformatting alone can't trip the flag, and it's robust if +// this function ever grows non-identity transforms). +function maskNode(node: unknown): MaskedNode { + if (Array.isArray(node)) { + let masked = false; + const out = node.map((item) => { + const r = maskNode(item); + masked = masked || r.masked; + return r.node; + }); + return { node: out, masked }; + } + if (node !== null && typeof node === "object") { + let masked = false; + const out: Record = {}; + for (const [key, value] of Object.entries( + node as Record, + )) { + if (isSensitiveKey(JSON_SENSITIVE_KEYS, key) && isMaskableValue(value)) { + out[key] = MASK_PLACEHOLDER; + masked = true; + } else { + const r = maskNode(value); + out[key] = r.node; + masked = masked || r.masked; + } + } + return { node: out, masked }; + } + return { node, masked: false }; +} + +export interface MaskResult { + /** The body with sensitive values replaced; pretty-printed for JSON, otherwise the original shape with values substituted. */ + masked: string; + /** True when at least one sensitive value was masked. */ + hasSecrets: boolean; +} + +function maskJsonBody(body: string): MaskResult { + let parsed: unknown; + try { + parsed = JSON.parse(body); + } catch { + return { masked: body, hasSecrets: false }; + } + const { node, masked } = maskNode(parsed); + return { masked: JSON.stringify(node, null, 2), hasSecrets: masked }; +} + +// Mask sensitive params in a form-urlencoded body, preserving the original +// formatting (we only swap the value, so the placeholder isn't percent-encoded +// the way `URLSearchParams.toString()` would mangle it). A non-form string +// (no `key=value` pairs with a sensitive key) falls through untouched. +function maskFormBody(body: string): MaskResult { + let hasSecrets = false; + const masked = body + .split("&") + .map((pair) => { + const eq = pair.indexOf("="); + if (eq === -1) return pair; + const rawKey = pair.slice(0, eq); + const value = pair.slice(eq + 1); + let key: string; + try { + key = decodeURIComponent(rawKey); + } catch { + key = rawKey; + } + if (isSensitiveKey(FORM_SENSITIVE_KEYS, key) && value.length > 0) { + hasSecrets = true; + return `${rawKey}=${MASK_PLACEHOLDER}`; + } + return pair; + }) + .join("&"); + return { masked: hasSecrets ? masked : body, hasSecrets }; +} + +/** + * Mask sensitive fields in an HTTP body for display. + * + * `contentType` (the body's `content-type` header, if known) picks the parser: + * - `*json*` → JSON masking (re-serialized pretty) + * - `application/x-www-form-urlencoded` → form masking (shape preserved) + * - any other known type (HTML, plaintext, XML, …) → no masking + * - absent/unknown → sniff: parse as JSON, else treat as form + * + * Bodies with no sensitive keys return unchanged with `hasSecrets: false` so + * callers can skip the reveal affordance. The caller keeps the original string + * for the revealed view. + * + * `contentType` is matched by substring (`*json*`, `*x-www-form-urlencoded*`) + * and we trust the wire's own label — a body mislabeled by the server (e.g. + * JSON sent as `text/html`) takes the "no masking" branch and renders raw. + * That's acceptable: the threat model is a screen-share viewer, not an + * adversary who controls the response's content-type. + */ +export function maskSecretsInBody( + body: string, + contentType?: string, +): MaskResult { + const ct = (contentType ?? "").toLowerCase(); + if (ct) { + if (ct.includes("json")) return maskJsonBody(body); + if (ct.includes("x-www-form-urlencoded")) return maskFormBody(body); + // Known, non-JSON/non-form content type → don't guess; leave it alone. + return { masked: body, hasSecrets: false }; + } + // No content-type hint: sniff. Valid JSON → JSON masking; otherwise form. + try { + JSON.parse(body); + } catch { + return maskFormBody(body); + } + return maskJsonBody(body); +} diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index e0f2254ce..d68b2a6df 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -315,16 +315,18 @@ export class InspectorClient extends InspectorClientEventTarget { private buildEffectiveAuthFetch(): typeof fetch { const base = this.fetchFn ?? fetch; - // Note: we deliberately do NOT wire `updateResponseBody` for the auth - // fetcher. OAuth token-exchange responses contain `access_token` and - // `refresh_token`; capturing them into FetchRequestLogState would - // surface live credentials in the Network tab body preview, which is - // easy to leak during a screen-share. Headers + status are still - // tracked. If a future need calls for inspecting auth bodies, add - // explicit secret redaction first. + // Capture auth response bodies (OAuth discovery, DCR, token exchange) so + // they're inspectable in the Network tab. Token-exchange responses carry + // `access_token` / `refresh_token`; the Network UI masks those (and other + // known secret fields) behind a click-to-reveal toggle so they aren't + // surfaced at a glance during a screen-share. Masking is a display + // concern, kept in the UI layer rather than mutating the captured entry, + // so the raw body stays available for the user who explicitly reveals it. return createFetchTracker(base, { trackRequest: (entry) => this.dispatchFetchRequest({ ...entry, category: "auth" }), + updateResponseBody: (id, body) => + this.dispatchFetchRequestBodyUpdate(id, body), }); } diff --git a/core/mcp/state/fetchRequestLogState.ts b/core/mcp/state/fetchRequestLogState.ts index 35f8f4e6b..9761a0d83 100644 --- a/core/mcp/state/fetchRequestLogState.ts +++ b/core/mcp/state/fetchRequestLogState.ts @@ -92,6 +92,11 @@ export class FetchRequestLogState extends TypedEventTarget, ): void => {