feat(core): redact sensitive headers in the fetch log (closes #1561)

Redact Authorization, Cookie, Set-Cookie, Proxy-Authorization, X-Api-Key,
and x-mcp-remote-auth to [REDACTED] in createFetchTracker before any
request/response entry reaches the in-memory log, pino logger, or
persisted session storage. Comparison is case-insensitive; original key
casing is preserved and the live outbound request is untouched.

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 18:19:59 -04:00
commit 381027063d
2 changed files with 167 additions and 8 deletions

View file

@ -1,5 +1,9 @@
import { describe, it, expect, vi } from "vitest";
import { createFetchTracker } from "@inspector/core/mcp/fetchTracking.js";
import {
createFetchTracker,
redactSensitiveHeaders,
REDACTED_HEADER_VALUE,
} from "@inspector/core/mcp/fetchTracking.js";
import type { FetchRequestEntryBase } from "@inspector/core/mcp/types.js";
// The tracker fires `trackRequest` synchronously with an entry whose
@ -8,6 +12,16 @@ import type { FetchRequestEntryBase } from "@inspector/core/mcp/types.js";
// microtask so the background read can complete before assertions.
const flush = () => new Promise((r) => setTimeout(r, 0));
// happy-dom's Headers.forEach preserves the original key casing whereas
// Node's lowercases — normalise in tests so assertions are env-independent.
function lowerKeys(
headers: Record<string, string> | undefined,
): Record<string, string> {
const out: Record<string, string> = {};
for (const [k, v] of Object.entries(headers ?? {})) out[k.toLowerCase()] = v;
return out;
}
describe("createFetchTracker", () => {
it("tracks a successful GET request and emits the response body asynchronously", async () => {
const baseFetch = vi.fn(
@ -262,4 +276,108 @@ describe("createFetchTracker", () => {
expect(tracked[0]?.responseBody).toBeUndefined();
expect(bodies).toHaveLength(0);
});
it("redacts Authorization and Cookie request headers in the recorded entry", 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),
});
await fetcher("https://example.com/mcp", {
method: "POST",
headers: {
Authorization: "Bearer live-access-token",
cookie: "session=secret",
"X-Api-Key": "sk-123",
"x-mcp-remote-auth": "Bearer inspector-backend-token",
"X-Custom": "kept",
},
});
const headers = lowerKeys(tracked[0]!.requestHeaders);
expect(headers["authorization"]).toBe(REDACTED_HEADER_VALUE);
expect(headers["cookie"]).toBe(REDACTED_HEADER_VALUE);
expect(headers["x-api-key"]).toBe(REDACTED_HEADER_VALUE);
expect(headers["x-mcp-remote-auth"]).toBe(REDACTED_HEADER_VALUE);
expect(headers["x-custom"]).toBe("kept");
expect(JSON.stringify(tracked[0])).not.toContain("live-access-token");
expect(JSON.stringify(tracked[0])).not.toContain("session=secret");
expect(JSON.stringify(tracked[0])).not.toContain("inspector-backend-token");
// The actual outbound request still carries the live token — redaction is
// only for the recorded entry.
expect(new Headers(outboundInit?.headers).get("authorization")).toBe(
"Bearer live-access-token",
);
});
it("redacts Authorization on the error path too", async () => {
const baseFetch = vi.fn(async () => {
throw new Error("network down");
});
const tracked: FetchRequestEntryBase[] = [];
const fetcher = createFetchTracker(baseFetch as typeof fetch, {
trackRequest: (entry) => tracked.push(entry),
});
await expect(
fetcher("https://example.com/fail", {
headers: { Authorization: "Bearer leaked-on-error" },
}),
).rejects.toThrow("network down");
expect(lowerKeys(tracked[0]!.requestHeaders)["authorization"]).toBe(
REDACTED_HEADER_VALUE,
);
expect(JSON.stringify(tracked[0])).not.toContain("leaked-on-error");
});
it("redacts sensitive response headers in the recorded entry", async () => {
// Set-Cookie is a forbidden response-header name in the Fetch API (the
// browser strips it from a constructed Response), so exercise the
// response-side redaction with x-api-key instead — it proves the same
// wiring without fighting the test environment.
const baseFetch = vi.fn(
async () =>
new Response("ok", {
headers: {
"x-api-key": "issued-secret",
"content-type": "text/plain",
},
}),
);
const tracked: FetchRequestEntryBase[] = [];
const fetcher = createFetchTracker(baseFetch as typeof fetch, {
trackRequest: (entry) => tracked.push(entry),
});
await fetcher("https://example.com/login");
const responseHeaders = lowerKeys(tracked[0]!.responseHeaders);
expect(responseHeaders["x-api-key"]).toBe(REDACTED_HEADER_VALUE);
expect(responseHeaders["content-type"]).toBe("text/plain");
expect(JSON.stringify(tracked[0])).not.toContain("issued-secret");
});
});
describe("redactSensitiveHeaders", () => {
it("redacts case-insensitively while preserving the original key casing", () => {
const out = redactSensitiveHeaders({
Authorization: "Bearer x",
"PROXY-AUTHORIZATION": "Basic y",
"X-Trace": "abc",
});
expect(out).toEqual({
Authorization: REDACTED_HEADER_VALUE,
"PROXY-AUTHORIZATION": REDACTED_HEADER_VALUE,
"X-Trace": "abc",
});
});
it("returns a new object and never mutates the input", () => {
const input = { authorization: "Bearer x" };
const out = redactSensitiveHeaders(input);
expect(out).not.toBe(input);
expect(input.authorization).toBe("Bearer x");
});
});

View file

@ -1,5 +1,43 @@
import type { FetchRequestEntryBase } from "./types.js";
/**
* Header names whose values are replaced with `REDACTED_HEADER_VALUE` before a
* fetch entry is recorded. The recorded entry flows to the in-memory log, the
* pino logger, and (via session storage) to disk — none of those sinks should
* ever see a live bearer token or session cookie.
*/
const SENSITIVE_HEADERS: ReadonlySet<string> = new Set([
"authorization",
"cookie",
"set-cookie",
"proxy-authorization",
"x-api-key",
// The inspector backend's own bearer (createRemoteFetch stamps this on every
// proxied request); same exposure as Authorization.
"x-mcp-remote-auth",
]);
/** Placeholder substituted for sensitive header values in recorded entries. */
export const REDACTED_HEADER_VALUE = "[REDACTED]";
/**
* Returns a copy of `headers` with every {@link SENSITIVE_HEADERS} value
* replaced by {@link REDACTED_HEADER_VALUE}. Comparison is case-insensitive
* (HTTP header names are case-insensitive); the original casing of every key is
* preserved so the recorded entry still shows what the client actually sent.
*/
export function redactSensitiveHeaders(
headers: Record<string, string>,
): Record<string, string> {
const out: Record<string, string> = {};
for (const [key, value] of Object.entries(headers)) {
out[key] = SENSITIVE_HEADERS.has(key.toLowerCase())
? REDACTED_HEADER_VALUE
: value;
}
return out;
}
/**
* Whether a response represents an unbounded (long-lived) HTTP stream
* whose body cannot be cloned + read to completion. The streamable HTTP
@ -57,19 +95,21 @@ export function createFetchTracker(
: input.url;
const method = init?.method || "GET";
// Extract headers
const requestHeaders: Record<string, string> = {};
// Extract headers, redacting sensitive values BEFORE they reach any
// downstream sink (logger, in-memory list, persisted session storage).
const rawRequestHeaders: Record<string, string> = {};
if (input instanceof Request) {
input.headers.forEach((value, key) => {
requestHeaders[key] = value;
rawRequestHeaders[key] = value;
});
}
if (init?.headers) {
const headers = new Headers(init.headers);
headers.forEach((value, key) => {
requestHeaders[key] = value;
rawRequestHeaders[key] = value;
});
}
const requestHeaders = redactSensitiveHeaders(rawRequestHeaders);
// Extract body (if present and readable)
let requestBody: string | undefined;
@ -122,11 +162,12 @@ export function createFetchTracker(
const responseStatus = response.status;
const responseStatusText = response.statusText;
// Extract response headers
const responseHeaders: Record<string, string> = {};
// Extract response headers (redacted — Set-Cookie etc. are credentials too)
const rawResponseHeaders: Record<string, string> = {};
response.headers.forEach((value, key) => {
responseHeaders[key] = value;
rawResponseHeaders[key] = value;
});
const responseHeaders = redactSensitiveHeaders(rawResponseHeaders);
// Skip body reading only for *long-lived* streams. On streamable HTTP,
// GET /mcp opens an unbounded SSE channel for server-to-client pushes