diff --git a/clients/web/src/test/core/mcp/fetchTracking.test.ts b/clients/web/src/test/core/mcp/fetchTracking.test.ts index df20f968..bae371ae 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 9e36e328..737bdc9c 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,