From 775b98a0190cb85602a21c2223fb0fae6f8983f9 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 1 Jul 2026 22:34:40 -0400 Subject: [PATCH] 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 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,