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:
parent
066676a241
commit
381027063d
2 changed files with 167 additions and 8 deletions
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue