feat(core): redact sensitive body + URL-query fields in fetch log (closes #1593)

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S3fTN8H3R8YV4yUGvZjYnX
This commit is contained in:
cliffhall 2026-07-01 22:20:42 -04:00
commit 524bf2d543

View file

@ -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<string, unknown>;
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", () => {