Skip to content

core: redact sensitive fields in fetch-log request/response bodies + URL query strings #1593

Closed

Description

@cliffhall

Background

Follow-up from #1561 / #1585 (redact sensitive headers in the fetch log). That slice masks Authorization, Cookie, Set-Cookie, Proxy-Authorization, X-Api-Key, and x-mcp-remote-auth on the recorded log entry. But core/mcp/fetchTracking.ts records four things per request — url, headers, requestBody, and responseBody — and only headers is redacted. The secrets that ride in the body and URL of those same requests are still logged verbatim.

Claude's review of #1585 flagged this as legitimately out of scope for the headers slice but recommended a tracked follow-up so it doesn't fall through the cracks. There was no existing issue for it.

The gap

For an OAuth token-endpoint exchange, the Authorization header is now masked, yet:

  • requestBody — the token request POSTs client_secret, code, refresh_token, code_verifier in the form body → logged in plaintext.
  • responseBody — the token endpoint returns access_token / refresh_token / id_token → logged in plaintext.
  • url — the full URL including query string is stored; some flows carry code/tokens as query params → logged verbatim.

Net: the network log still leaks the exact credentials the header redaction was meant to protect.

Scope

In core/mcp/fetchTracking.ts, before an entry reaches any sink, redact sensitive material in:

  1. Request/response bodies — for form-encoded and JSON bodies, redact known-sensitive fields (client_secret, code, refresh_token, access_token, id_token, code_verifier, client_assertion, assertion, password, token). Preserve the surrounding shape so the log stays useful; replace only the values. Leave non-sensitive / non-parseable bodies untouched (best-effort parse, never throw).
  2. URL query strings — redact the same sensitive param names in the stored url while keeping the path + non-sensitive params readable.

Reuse the header slice's REDACTED_HEADER_VALUE-style sentinel and case-insensitive matching. Live outbound requests must remain untouched — only the logged copy is redacted.

Acceptance

  • OAuth token request/response bodies and any token-bearing URL query params show the redaction sentinel in the fetch log; live requests are byte-identical to before.
  • Non-sensitive bodies/URLs are unchanged and still logged.
  • Malformed/unparseable bodies don't throw — they log as-is (or as an opaque redaction if they can't be safely inspected).
  • Per-file coverage ≥90 on all four dimensions for the touched code, expected-error output suppressed in tests.

Refs: #1561, #1585, tracking #1579.

Activity

  1. self-assigned this
    on Jul 1, 2026
  2. cliffhall commented on Jul 2, 2026

    @cliffhall
    MemberAuthor

    Smoke test & AGENTS.md audit — PR #1598

    Independent verification of 1593-redact-bodies (base 1579-wave-1, HEAD 775b98a). Detached-checkout of origin/1593-redact-bodies, fresh npm install, Node v25.9.0.

    Smoke method

    A standalone tsx script imported the real exports from core/mcp/fetchTracking.ts and drove both the helpers directly (redactBody, redactUrlQuery, redactSensitiveHeaders) and the full tracker entry path (createFetchTracker + trackRequest/updateResponseBody with a fake fetch that captures exactly what was forwarded to the network). 40/40 checks passed. No Playwright — this is a pure core module with no visual surface.

    Real before/after output

    Form-encoded OAuth token body — code/client_secret/refresh_token masked, grant_type/redirect_uri kept:

    BEFORE: grant_type=authorization_code&code=abc123&client_secret=xyzSECRET&refresh_token=rt_secret_value&redirect_uri=http%3A%2F%2Flocalhost%2Fcb
    AFTER : grant_type=authorization_code&code=%5BREDACTED%5D&client_secret=%5BREDACTED%5D&refresh_token=%5BREDACTED%5D&redirect_uri=http%3A%2F%2Flocalhost%2Fcb
    

    Nested JSON body — recursive masking of access_token/id_token/refresh_token/password (incl. inside arrays), non-sensitive fields kept:

    BEFORE: {"token_type":"Bearer","expires_in":3600,"access_token":"AT_super_secret","scope":"read write","nested":{"id_token":"ID_secret","inner":{"refresh_token":"RT_deep"},"safe":"keepme"},"arr":[{"password":"pw_secret"},{"ok":1}]}
    AFTER : {"token_type":"Bearer","expires_in":3600,"access_token":"[REDACTED]","scope":"read write","nested":{"id_token":"[REDACTED]","inner":{"refresh_token":"[REDACTED]"},"safe":"keepme"},"arr":[{"password":"[REDACTED]"},{"ok":1}]}
    

    URL query — code/client_secret masked, path + state/foo readable, fragment preserved:

    BEFORE: https://auth.example.com/callback?code=AUTHCODE123&state=xyzState&client_secret=CS_secret&foo=bar#frag
    AFTER : https://auth.example.com/callback?code=%5BREDACTED%5D&state=xyzState&client_secret=%5BREDACTED%5D&foo=bar#frag
    

    Malformed / non-parseable inputs pass through unchanged (no throw): malformed form %%%not-valid=&&&, malformed JSON {not json at all, empty "", and unparseable URL ::::not a url:::: all returned verbatim.

    Live request provably untouched — the fake fetch captured what the tracker forwarded; only the logged entry is redacted:

    LIVE input forwarded to fetch : https://auth.example.com/token?code=LIVECODE&safe=1
    LIVE body forwarded to fetch  : grant_type=authorization_code&code=LIVECODE&client_secret=LIVESECRET
    LIVE auth header forwarded    : Bearer LIVE_BEARER
    
    LOGGED url        : https://auth.example.com/token?code=%5BREDACTED%5D&safe=1
    LOGGED requestBody: grant_type=authorization_code&code=%5BREDACTED%5D&client_secret=%5BREDACTED%5D
    LOGGED reqHeaders : {"authorization":"[REDACTED]","content-type":"application/x-www-form-urlencoded"}
    LOGGED respBody   : {"access_token":"[REDACTED]","token_type":"Bearer"}
    

    The live URL and body strings are ===-identical to the originals and the live Authorization header is the real bearer — redaction is confined entirely to the recorded copy.

    AGENTS.md compliance

    • Mantine: N/A — this is core (core/mcp/), no UI surface.

    • TypeScript: No any, no as any, no eslint-disable / @ts-ignore / @ts-expect-error in the diff. New helpers use unknown + Record<string, unknown> and narrow properly.

    • Coverage (clients/web test:coverage, unit + integration, gate ≥90 on all four dims):

      File % Stmts % Branch % Funcs % Lines
      core/mcp/fetchTracking.ts 98.27 98.55 100 98.18

      All four dimensions clear the 90 gate; the full run passed (234 files / 3372 tests) with no threshold error.

    • Integration: test:integration — 39 files / 809 tests passed.

    • Header redaction from feat(core): redact sensitive headers in the fetch log #1585 intact: SENSITIVE_HEADERS set and redactSensitiveHeaders() are unchanged and still called for both request (line 261) and response (line 328) headers; the new body/URL logic is additive (a separate SENSITIVE_BODY_FIELDS set + REDACTED_VALUE sentinel, deliberately kept distinct from REDACTED_HEADER_VALUE). Not removed, not duplicated. The live smoke run confirmed the auth header is still masked in the logged entry.

    Verdict: PASS

    Body + URL redaction works recursively and case-insensitively, malformed input is safe, and the live outbound request (URL, body, headers) is provably byte-identical to the original — only the logged copy is redacted. Header redaction from #1585 remains intact. Coverage and integration are green.

    Smoke test script (tsx, not committed)
    import {
      createFetchTracker,
      redactBody,
      redactUrlQuery,
      redactSensitiveHeaders,
      REDACTED_VALUE,
      REDACTED_HEADER_VALUE,
    } from "core/mcp/fetchTracking.ts";
    import type { FetchRequestEntryBase } from "core/mcp/types.ts";
    
    const line = (s = "") => console.log(s);
    let failures = 0;
    function check(label: string, cond: boolean) {
      line(`  [${cond ? "PASS" : "FAIL"}] ${label}`);
      if (!cond) failures++;
    }
    
    // 1. form-encoded OAuth token request
    const formBody =
      "grant_type=authorization_code&code=abc123&client_secret=xyzSECRET&refresh_token=rt_secret_value&redirect_uri=http%3A%2F%2Flocalhost%2Fcb";
    const formRedacted = redactBody(formBody, "application/x-www-form-urlencoded");
    check("grant_type kept", /grant_type=authorization_code/.test(formRedacted!));
    check("client_secret masked", !formRedacted!.includes("xyzSECRET"));
    check("refresh_token masked", !formRedacted!.includes("rt_secret_value"));
    check("code masked", !formRedacted!.includes("abc123"));
    
    // 2. nested JSON
    const jsonBody = JSON.stringify({
      token_type: "Bearer", expires_in: 3600, access_token: "AT_super_secret", scope: "read write",
      nested: { id_token: "ID_secret", inner: { refresh_token: "RT_deep" }, safe: "keepme" },
      arr: [{ password: "pw_secret" }, { ok: 1 }],
    });
    const parsed = JSON.parse(redactBody(jsonBody, "application/json")!);
    check("access_token masked", parsed.access_token === REDACTED_VALUE);
    check("nested id_token masked", parsed.nested.id_token === REDACTED_VALUE);
    check("deep refresh_token masked", parsed.nested.inner.refresh_token === REDACTED_VALUE);
    check("nested safe kept", parsed.nested.safe === "keepme");
    check("array password masked", parsed.arr[0].password === REDACTED_VALUE);
    check("array ok kept", parsed.arr[1].ok === 1);
    
    // 3. URL query
    const urlRedacted = redactUrlQuery(
      "https://auth.example.com/callback?code=AUTHCODE123&state=xyzState&client_secret=CS_secret&foo=bar#frag");
    check("state kept", /state=xyzState/.test(urlRedacted));
    check("foo kept", /foo=bar/.test(urlRedacted));
    check("code masked", !urlRedacted.includes("AUTHCODE123"));
    check("client_secret masked", !urlRedacted.includes("CS_secret"));
    check("fragment preserved", urlRedacted.endsWith("#frag"));
    
    // 4. malformed input (no throw)
    let threw = false;
    try {
      redactBody("%%%not-valid=&&&", "application/x-www-form-urlencoded");
      const mfJson = redactBody("{not json at all", "application/json");
      redactUrlQuery("::::not a url::::");
      check("malformed json unchanged", mfJson === "{not json at all");
    } catch { threw = true; }
    check("no throw on malformed input", !threw);
    
    // 5. LIVE request byte-identical, only logged copy redacted
    const liveUrl = "https://auth.example.com/token?code=LIVECODE&safe=1";
    const liveBody = "grant_type=authorization_code&code=LIVECODE&client_secret=LIVESECRET";
    let seenInput: RequestInfo | URL | undefined;
    let seenInit: RequestInit | undefined;
    const fakeFetch: typeof fetch = async (input, init) => {
      seenInput = input; seenInit = init;
      return new Response(JSON.stringify({ access_token: "LIVE_AT", token_type: "Bearer" }),
        { status: 200, headers: { "content-type": "application/json" } });
    };
    const captured: FetchRequestEntryBase[] = [];
    const bodyUpdates: Record<string, string> = {};
    const tracked = createFetchTracker(fakeFetch, {
      trackRequest: (e) => captured.push(e),
      updateResponseBody: (id, body) => { bodyUpdates[id] = body; },
    });
    await tracked(liveUrl, {
      method: "POST",
      headers: { "content-type": "application/x-www-form-urlencoded", authorization: "Bearer LIVE_BEARER" },
      body: liveBody,
    });
    await new Promise((r) => setTimeout(r, 20));
    const entry = captured[0];
    check("live URL untouched", seenInput === liveUrl);
    check("live body untouched", seenInit?.body === liveBody);
    check("live auth header untouched",
      (seenInit?.headers as Record<string, string>)?.authorization === "Bearer LIVE_BEARER");
    check("logged URL code masked", !entry.url.includes("LIVECODE"));
    check("logged reqBody client_secret masked", !entry.requestBody!.includes("LIVESECRET"));
    check("logged auth header masked (#1585 intact)",
      entry.requestHeaders.authorization === REDACTED_HEADER_VALUE);
    check("logged response access_token masked", !bodyUpdates[entry.id].includes("LIVE_AT"));
    check("logged response token_type kept", bodyUpdates[entry.id].includes("Bearer"));
    
    // 6. header redaction sanity
    const hdr = redactSensitiveHeaders({ Authorization: "Bearer X", "content-type": "application/json", cookie: "sid=1" });
    check("Authorization masked", hdr.Authorization === REDACTED_HEADER_VALUE);
    check("cookie masked", hdr.cookie === REDACTED_HEADER_VALUE);
    check("content-type kept", hdr["content-type"] === "application/json");
    
    line(failures === 0 ? "ALL CHECKS PASSED" : `${failures} FAILED`);
    process.exit(failures === 0 ? 0 : 1);

    Automated smoke test & audit run against 775b98a.

  3. added a commit that references this issue on Jul 2, 2026
  4. cliffhall commented on Jul 2, 2026

    @cliffhall
    MemberAuthor

    🔗 Implemented by PR #1598, merged into the Wave 1 rollup branch 1579-wave-1. GitHub's Development panel doesn't show this link because #1598 targeted a non-default branch. Rollup: #1600 / #1601.

  5. added a commit that references this issue on Jul 28, 2026
    524bf2d
  6. added this to the v2.0.0 milestone on Jul 28, 2026
  7. added
    authIssues and PRs related to authorization
    on Aug 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

authIssues and PRs related to authorizationv2Issues and PRs for v2

Type

No type

Projects

No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions