From 524bf2d54394b882003ef1234247fcbf2b7346e9 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 1 Jul 2026 22:20:42 -0400 Subject: [PATCH 1/2] feat(core): redact sensitive body + URL-query fields in fetch log (closes #1593) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the header redaction from #1585. Before a fetch entry reaches any sink, mask known-sensitive credentials in the request/response bodies and the URL query string, in addition to headers: - redactBody(): form-urlencoded + JSON bodies, recursive over nested objects/arrays, best-effort (never throws), values-only. - redactUrlQuery(): masks sensitive query params, preserves path, non-sensitive params, and any fragment. - Sensitive fields: client_secret, code, refresh_token, access_token, id_token, code_verifier, client_assertion, assertion, password, token (case-insensitive), reusing the [REDACTED] sentinel. Applied to the request success + error entries and the async response-body callback. Live outbound requests are byte-identical — only the logged copy is redacted. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01S3fTN8H3R8YV4yUGvZjYnX --- .../src/test/core/mcp/fetchTracking.test.ts | 239 ++++++++++++++++++ core/mcp/fetchTracking.ts | 164 +++++++++++- 2 files changed, 398 insertions(+), 5 deletions(-) diff --git a/clients/web/src/test/core/mcp/fetchTracking.test.ts b/clients/web/src/test/core/mcp/fetchTracking.test.ts index d14fa477d..df20f9688 100644 --- a/clients/web/src/test/core/mcp/fetchTracking.test.ts +++ b/clients/web/src/test/core/mcp/fetchTracking.test.ts @@ -2,7 +2,10 @@ import { describe, it, expect, vi } from "vitest"; import { createFetchTracker, redactSensitiveHeaders, + redactBody, + redactUrlQuery, REDACTED_HEADER_VALUE, + REDACTED_VALUE, } from "@inspector/core/mcp/fetchTracking.js"; import type { FetchRequestEntryBase } from "@inspector/core/mcp/types.js"; @@ -358,6 +361,242 @@ describe("createFetchTracker", () => { expect(responseHeaders["content-type"]).toBe("text/plain"); expect(JSON.stringify(tracked[0])).not.toContain("issued-secret"); }); + + it("redacts a form-encoded OAuth token request body without touching the live request", async () => { + let outboundInit: RequestInit | undefined; + const baseFetch = vi.fn( + async (_input: RequestInfo | URL, init?: RequestInit) => { + outboundInit = init; + return new Response("ok"); + }, + ); + const tracked: FetchRequestEntryBase[] = []; + const fetcher = createFetchTracker(baseFetch as typeof fetch, { + trackRequest: (entry) => tracked.push(entry), + }); + const liveBody = + "grant_type=authorization_code&code=secret-auth-code&client_secret=shh&code_verifier=pkce123&client_id=public"; + await fetcher("https://auth.example.com/token", { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: liveBody, + }); + const recorded = new URLSearchParams(tracked[0]!.requestBody); + expect(recorded.get("code")).toBe(REDACTED_VALUE); + expect(recorded.get("client_secret")).toBe(REDACTED_VALUE); + expect(recorded.get("code_verifier")).toBe(REDACTED_VALUE); + expect(recorded.get("grant_type")).toBe("authorization_code"); + expect(recorded.get("client_id")).toBe("public"); + expect(JSON.stringify(tracked[0])).not.toContain("secret-auth-code"); + expect(JSON.stringify(tracked[0])).not.toContain("shh"); + expect(JSON.stringify(tracked[0])).not.toContain("pkce123"); + // The live outbound request body is byte-identical. + expect(outboundInit?.body).toBe(liveBody); + }); + + it("redacts a JSON token response body asynchronously", async () => { + const baseFetch = vi.fn( + async () => + new Response( + JSON.stringify({ + access_token: "live-access", + refresh_token: "live-refresh", + token_type: "Bearer", + expires_in: 3600, + }), + { headers: { "content-type": "application/json" } }, + ), + ); + const tracked: FetchRequestEntryBase[] = []; + const bodies: Array<{ id: string; body: string }> = []; + const fetcher = createFetchTracker(baseFetch as typeof fetch, { + trackRequest: (entry) => tracked.push(entry), + updateResponseBody: (id, body) => bodies.push({ id, body }), + }); + await fetcher("https://auth.example.com/token", { method: "POST" }); + await flush(); + expect(bodies).toHaveLength(1); + const parsed = JSON.parse(bodies[0]!.body) as Record; + expect(parsed.access_token).toBe(REDACTED_VALUE); + expect(parsed.refresh_token).toBe(REDACTED_VALUE); + expect(parsed.token_type).toBe("Bearer"); + expect(parsed.expires_in).toBe(3600); + expect(bodies[0]!.body).not.toContain("live-access"); + expect(bodies[0]!.body).not.toContain("live-refresh"); + }); + + it("redacts sensitive query params in the recorded URL (success + error paths)", async () => { + const baseFetch = vi.fn(async () => new Response("ok")); + const tracked: FetchRequestEntryBase[] = []; + const fetcher = createFetchTracker(baseFetch as typeof fetch, { + trackRequest: (entry) => tracked.push(entry), + }); + await fetcher( + "https://auth.example.com/callback?state=xyz&code=secret-code&access_token=leaky", + ); + expect(tracked[0]!.url).toContain("state=xyz"); + expect(tracked[0]!.url).toContain( + `code=${encodeURIComponent(REDACTED_VALUE)}`, + ); + expect(tracked[0]!.url).not.toContain("secret-code"); + expect(tracked[0]!.url).not.toContain("leaky"); + + const failing = createFetchTracker( + vi.fn(async () => { + throw new Error("boom"); + }) as typeof fetch, + { trackRequest: (entry) => tracked.push(entry) }, + ); + await expect( + failing("https://auth.example.com/token?refresh_token=leaked-on-error"), + ).rejects.toThrow("boom"); + expect(tracked[1]!.url).not.toContain("leaked-on-error"); + }); + + it("leaves non-sensitive bodies and URLs untouched", async () => { + const baseFetch = vi.fn( + async () => + new Response('{"tools":[]}', { + headers: { "content-type": "application/json" }, + }), + ); + const tracked: FetchRequestEntryBase[] = []; + const bodies: Array<{ id: string; body: string }> = []; + const fetcher = createFetchTracker(baseFetch as typeof fetch, { + trackRequest: (entry) => tracked.push(entry), + updateResponseBody: (id, body) => bodies.push({ id, body }), + }); + await fetcher("https://example.com/mcp?page=2", { + method: "POST", + headers: { "content-type": "application/json" }, + body: '{"method":"tools/list"}', + }); + await flush(); + expect(tracked[0]!.url).toBe("https://example.com/mcp?page=2"); + expect(tracked[0]!.requestBody).toBe('{"method":"tools/list"}'); + expect(bodies[0]!.body).toBe('{"tools":[]}'); + }); + + it("does not throw on a malformed body — logs it as-is", async () => { + const baseFetch = vi.fn(async () => new Response("ok")); + const tracked: FetchRequestEntryBase[] = []; + const fetcher = createFetchTracker(baseFetch as typeof fetch, { + trackRequest: (entry) => tracked.push(entry), + }); + const malformed = "{ this is not: valid json ]]"; + await fetcher("https://example.com/x", { + method: "POST", + headers: { "content-type": "application/json" }, + body: malformed, + }); + expect(tracked[0]!.requestBody).toBe(malformed); + }); +}); + +describe("redactUrlQuery", () => { + it("redacts sensitive params and keeps the path + other params", () => { + expect( + redactUrlQuery("https://x.example/cb?state=ok&code=abc&client_secret=s"), + ).toBe( + `https://x.example/cb?state=ok&code=${encodeURIComponent( + REDACTED_VALUE, + )}&client_secret=${encodeURIComponent(REDACTED_VALUE)}`, + ); + }); + + it("returns URLs without a query string unchanged", () => { + expect(redactUrlQuery("https://x.example/path")).toBe( + "https://x.example/path", + ); + }); + + it("returns URLs with only non-sensitive params unchanged", () => { + expect(redactUrlQuery("https://x.example/p?a=1&b=2")).toBe( + "https://x.example/p?a=1&b=2", + ); + }); + + it("matches param names case-insensitively", () => { + const out = redactUrlQuery("https://x.example/cb?CODE=abc"); + expect(out).toContain(encodeURIComponent(REDACTED_VALUE)); + expect(out).not.toContain("abc"); + }); + + it("preserves a trailing fragment", () => { + expect(redactUrlQuery("https://x.example/p?token=t#section")).toBe( + `https://x.example/p?token=${encodeURIComponent(REDACTED_VALUE)}#section`, + ); + }); + + it("collapses repeated sensitive params to a single redacted value", () => { + expect(redactUrlQuery("https://x.example/p?code=a&code=b")).toBe( + `https://x.example/p?code=${encodeURIComponent(REDACTED_VALUE)}`, + ); + }); +}); + +describe("redactBody", () => { + it("returns undefined / empty bodies unchanged", () => { + expect(redactBody(undefined, "application/json")).toBeUndefined(); + expect(redactBody("", "application/json")).toBe(""); + }); + + it("redacts form-encoded fields (with a charset in the content-type)", () => { + const out = redactBody( + "client_secret=s&grant_type=client_credentials", + "application/x-www-form-urlencoded; charset=utf-8", + ); + const params = new URLSearchParams(out); + expect(params.get("client_secret")).toBe(REDACTED_VALUE); + expect(params.get("grant_type")).toBe("client_credentials"); + }); + + it("leaves a form body with no sensitive fields byte-identical", () => { + const body = "grant_type=client_credentials&scope=read"; + expect(redactBody(body, "application/x-www-form-urlencoded")).toBe(body); + }); + + it("redacts nested JSON objects and arrays", () => { + const out = redactBody( + JSON.stringify({ + outer: { password: "p", keep: "v" }, + list: [{ token: "t1" }, { token: "t2" }], + }), + "application/json", + ); + const parsed = JSON.parse(out!) as { + outer: { password: string; keep: string }; + list: Array<{ token: string }>; + }; + expect(parsed.outer.password).toBe(REDACTED_VALUE); + expect(parsed.outer.keep).toBe("v"); + expect(parsed.list.map((e) => e.token)).toEqual([ + REDACTED_VALUE, + REDACTED_VALUE, + ]); + }); + + it("sniffs JSON when the content-type is missing", () => { + const out = redactBody('{"access_token":"x"}', undefined); + expect(JSON.parse(out!)).toEqual({ access_token: REDACTED_VALUE }); + }); + + it("leaves a JSON scalar (no field names) unchanged", () => { + expect(redactBody('"just a string"', "application/json")).toBe( + '"just a string"', + ); + }); + + it("returns a non-JSON, non-form body unchanged", () => { + expect(redactBody("plain text log line", "text/plain")).toBe( + "plain text log line", + ); + }); + + it("does not throw on malformed JSON", () => { + const bad = "{not json"; + expect(redactBody(bad, "application/json")).toBe(bad); + }); }); describe("redactSensitiveHeaders", () => { diff --git a/core/mcp/fetchTracking.ts b/core/mcp/fetchTracking.ts index a25e24786..9e36e328f 100644 --- a/core/mcp/fetchTracking.ts +++ b/core/mcp/fetchTracking.ts @@ -20,6 +20,35 @@ const SENSITIVE_HEADERS: ReadonlySet = new Set([ /** Placeholder substituted for sensitive header values in recorded entries. */ export const REDACTED_HEADER_VALUE = "[REDACTED]"; +/** + * Field / query-parameter names whose values are masked in a recorded fetch + * entry's request body, response body, and URL query string. These are the + * credentials that ride in OAuth token exchanges (and similar flows): the + * header slice masks `Authorization`, but the same secrets show up verbatim in + * the form/JSON body (`client_secret`, `code`, `refresh_token`, …) and are + * sometimes carried as URL query params. Matching is case-insensitive. + */ +const SENSITIVE_BODY_FIELDS: ReadonlySet = new Set([ + "client_secret", + "code", + "refresh_token", + "access_token", + "id_token", + "code_verifier", + "client_assertion", + "assertion", + "password", + "token", +]); + +/** Placeholder substituted for sensitive body / URL values in recorded entries. */ +export const REDACTED_VALUE = "[REDACTED]"; + +/** Whether `name` (any casing) is a known-sensitive field / query-param name. */ +function isSensitiveField(name: string): boolean { + return SENSITIVE_BODY_FIELDS.has(name.toLowerCase()); +} + /** * Returns a copy of `headers` with every {@link SENSITIVE_HEADERS} value * replaced by {@link REDACTED_HEADER_VALUE}. Comparison is case-insensitive @@ -38,6 +67,116 @@ export function redactSensitiveHeaders( return out; } +/** + * Returns `url` with every {@link SENSITIVE_BODY_FIELDS} query-parameter value + * replaced by {@link REDACTED_VALUE}. The path and non-sensitive params stay + * readable. Best-effort: if the URL (or its query string) can't be parsed the + * original string is returned unchanged. Only the recorded copy is redacted — + * the live request still uses the original `input`/`init`. + */ +export function redactUrlQuery(url: string): string { + const queryStart = url.indexOf("?"); + if (queryStart === -1) return url; + + const base = url.slice(0, queryStart); + const afterQuery = url.slice(queryStart + 1); + // Preserve a trailing fragment (#…) untouched — it never carries query params. + const hashStart = afterQuery.indexOf("#"); + const query = hashStart === -1 ? afterQuery : afterQuery.slice(0, hashStart); + const fragment = hashStart === -1 ? "" : afterQuery.slice(hashStart); + + try { + const params = new URLSearchParams(query); + let changed = false; + for (const key of new Set(params.keys())) { + if (isSensitiveField(key)) { + changed = true; + // Collapse repeated occurrences to a single redacted value. + params.set(key, REDACTED_VALUE); + } + } + if (!changed) return url; + return `${base}?${params.toString()}${fragment}`; + } catch { + return url; + } +} + +/** Recursively redact sensitive keys in a parsed JSON value (in place). */ +function redactJsonValue(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(redactJsonValue); + } + if (value !== null && typeof value === "object") { + const out: Record = {}; + for (const [key, val] of Object.entries(value)) { + out[key] = isSensitiveField(key) ? REDACTED_VALUE : redactJsonValue(val); + } + return out; + } + return value; +} + +/** + * Returns `body` with every {@link SENSITIVE_BODY_FIELDS} value masked, for + * `application/x-www-form-urlencoded` and JSON payloads. The surrounding shape + * (field order, non-sensitive fields, JSON structure) is preserved — only the + * values change. Best-effort and never throws: an empty, non-string, or + * unparseable body is returned unchanged. Only the recorded copy is redacted; + * the live request body is never touched. + */ +export function redactBody( + body: string | undefined, + contentType: string | null | undefined, +): string | undefined { + if (!body) return body; + + const type = (contentType ?? "").toLowerCase(); + + // Form-encoded bodies (the OAuth token endpoint's request format). + if (type.includes("application/x-www-form-urlencoded")) { + try { + const params = new URLSearchParams(body); + let changed = false; + for (const key of new Set(params.keys())) { + if (isSensitiveField(key)) { + changed = true; + params.set(key, REDACTED_VALUE); + } + } + return changed ? params.toString() : body; + } catch { + return body; + } + } + + // JSON bodies — either explicitly typed, or (when the content-type is + // missing/other) any string that parses as a JSON object/array. A bare + // JSON scalar has no field names, so it can't carry a sensitive key. + try { + const parsed: unknown = JSON.parse(body); + if (parsed !== null && typeof parsed === "object") { + return JSON.stringify(redactJsonValue(parsed)); + } + } catch { + // Not JSON — fall through and leave as-is. + } + + return body; +} + +/** Case-insensitive lookup of a header value from a plain header record. */ +function findHeader( + headers: Record, + name: string, +): string | undefined { + const target = name.toLowerCase(); + for (const [key, value] of Object.entries(headers)) { + if (key.toLowerCase() === target) return value; + } + return undefined; +} + /** * Whether a response represents an unbounded (long-lived) HTTP stream * whose body cannot be cloned + read to completion. The streamable HTTP @@ -110,6 +249,11 @@ export function createFetchTracker( }); } const requestHeaders = redactSensitiveHeaders(rawRequestHeaders); + const requestContentType = findHeader(rawRequestHeaders, "content-type"); + + // Redact sensitive query params in the recorded URL (live `input` is + // untouched — only this logged copy is masked). + const redactedUrl = redactUrlQuery(url); // Extract body (if present and readable) let requestBody: string | undefined; @@ -136,6 +280,10 @@ export function createFetchTracker( } } + // Redact sensitive fields in the recorded request body. The live request + // body (`init.body` / `input`) is never touched — only this logged string. + const redactedRequestBody = redactBody(requestBody, requestContentType); + // Make the actual fetch request let response: Response; let error: string | undefined; @@ -148,9 +296,9 @@ export function createFetchTracker( id, timestamp, method, - url, + url: redactedUrl, requestHeaders, - requestBody, + requestBody: redactedRequestBody, error, duration: Date.now() - startTime, }; @@ -193,9 +341,9 @@ export function createFetchTracker( id, timestamp, method, - url, + url: redactedUrl, requestHeaders, - requestBody, + requestBody: redactedRequestBody, responseStatus, responseStatusText, responseHeaders, @@ -211,12 +359,18 @@ export function createFetchTracker( // via `updateResponseBody`. Skipped for long-lived streams (GET + // SSE / ndjson) because `.text()` would never resolve on those. if (!isLongLivedStream && response.body && !response.bodyUsed) { + const responseContentType = response.headers.get("content-type"); try { const cloned = response.clone(); cloned .text() .then((body) => { - callbacks.updateResponseBody?.(id, body); + // Mask token-endpoint secrets (access_token, refresh_token, …) + // before the body reaches any sink. + callbacks.updateResponseBody?.( + id, + redactBody(body, responseContentType) ?? body, + ); }) .catch(() => { // Stream errored after clone — leave the body undefined. From 775b98a0190cb85602a21c2223fb0fae6f8983f9 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 1 Jul 2026 22:34:40 -0400 Subject: [PATCH 2/2] docs(core): clarify redaction scope + add nested request-body test Address round-1 review feedback on fetchTracking redaction: - Cross-reference REDACTED_VALUE / REDACTED_HEADER_VALUE so a reader understands why two identical "[REDACTED]" constants coexist. - Document that redactBody deliberately scopes to form-encoded + JSON (multipart/binary bodies pass through; OAuth never uses multipart). - Add a tracker-path test asserting a nested sensitive key in a JSON request body is redacted in the recorded copy while the live outbound body stays byte-identical. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01S3fTN8H3R8YV4yUGvZjYnX --- .../src/test/core/mcp/fetchTracking.test.ts | 29 +++++++++++++++++++ core/mcp/fetchTracking.ts | 12 +++++++- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/clients/web/src/test/core/mcp/fetchTracking.test.ts b/clients/web/src/test/core/mcp/fetchTracking.test.ts index df20f9688..bae371ae7 100644 --- a/clients/web/src/test/core/mcp/fetchTracking.test.ts +++ b/clients/web/src/test/core/mcp/fetchTracking.test.ts @@ -477,6 +477,35 @@ describe("createFetchTracker", () => { expect(bodies[0]!.body).toBe('{"tools":[]}'); }); + it("redacts a nested sensitive key in a JSON request body through the tracker", async () => { + let outboundInit: RequestInit | undefined; + const baseFetch = vi.fn( + async (_input: RequestInfo | URL, init?: RequestInit) => { + outboundInit = init; + return new Response("ok"); + }, + ); + const tracked: FetchRequestEntryBase[] = []; + const fetcher = createFetchTracker(baseFetch as typeof fetch, { + trackRequest: (entry) => tracked.push(entry), + }); + const liveBody = JSON.stringify({ + params: { arguments: { access_token: "sekret", page: 2 } }, + }); + await fetcher("https://example.com/mcp", { + method: "POST", + headers: { "content-type": "application/json" }, + body: liveBody, + }); + // Recorded copy has the nested secret masked; non-sensitive siblings kept. + expect(JSON.parse(tracked[0]!.requestBody!)).toEqual({ + params: { arguments: { access_token: REDACTED_VALUE, page: 2 } }, + }); + expect(JSON.stringify(tracked[0])).not.toContain("sekret"); + // The live outbound request body is byte-identical (unredacted). + expect(outboundInit?.body).toBe(liveBody); + }); + it("does not throw on a malformed body — logs it as-is", async () => { const baseFetch = vi.fn(async () => new Response("ok")); const tracked: FetchRequestEntryBase[] = []; diff --git a/core/mcp/fetchTracking.ts b/core/mcp/fetchTracking.ts index 9e36e328f..737bdc9c9 100644 --- a/core/mcp/fetchTracking.ts +++ b/core/mcp/fetchTracking.ts @@ -41,7 +41,12 @@ const SENSITIVE_BODY_FIELDS: ReadonlySet = new Set([ "token", ]); -/** Placeholder substituted for sensitive body / URL values in recorded entries. */ +/** + * Placeholder substituted for sensitive body / URL values in recorded entries. + * Deliberately kept separate from {@link REDACTED_HEADER_VALUE} (even though both + * are `"[REDACTED]"` today) so the header and body/URL redaction paths can evolve + * their sentinels independently. + */ export const REDACTED_VALUE = "[REDACTED]"; /** Whether `name` (any casing) is a known-sensitive field / query-param name. */ @@ -124,6 +129,11 @@ function redactJsonValue(value: unknown): unknown { * values change. Best-effort and never throws: an empty, non-string, or * unparseable body is returned unchanged. Only the recorded copy is redacted; * the live request body is never touched. + * + * Scope is deliberately limited to `application/x-www-form-urlencoded` and JSON: + * these cover the OAuth token flows this redaction targets. `multipart/form-data` + * (and other binary/opaque bodies) are passed through verbatim — OAuth never uses + * multipart, so the risk is low; revisit if a multipart secret path appears. */ export function redactBody( body: string | undefined,