diff --git a/clients/web/src/test/core/mcp/fetchTracking.test.ts b/clients/web/src/test/core/mcp/fetchTracking.test.ts index c83057cc..d14fa477 100644 --- a/clients/web/src/test/core/mcp/fetchTracking.test.ts +++ b/clients/web/src/test/core/mcp/fetchTracking.test.ts @@ -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 | undefined, +): Record { + const out: Record = {}; + 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"); + }); }); diff --git a/core/mcp/fetchTracking.ts b/core/mcp/fetchTracking.ts index 27eb6269..a25e2478 100644 --- a/core/mcp/fetchTracking.ts +++ b/core/mcp/fetchTracking.ts @@ -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 = 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, +): Record { + const out: Record = {}; + 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 = {}; + // Extract headers, redacting sensitive values BEFORE they reach any + // downstream sink (logger, in-memory list, persisted session storage). + const rawRequestHeaders: Record = {}; 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 = {}; + // Extract response headers (redacted — Set-Cookie etc. are credentials too) + const rawResponseHeaders: Record = {}; 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