diff --git a/src/proxy-capture/runtime.test.ts b/src/proxy-capture/runtime.test.ts index 8024cd3320f..d56d599caab 100644 --- a/src/proxy-capture/runtime.test.ts +++ b/src/proxy-capture/runtime.test.ts @@ -78,6 +78,42 @@ describe("debug proxy runtime", () => { expect(sessionEvents.some((event) => event.kind === "response")).toBe(true); }); + it("normalizes symbol-bearing request headers before calling patched fetch targets", async () => { + fetchTarget.fetch = async (_input: RequestInfo | URL, init?: RequestInit) => { + const headers = new Headers(init?.headers); + expect(headers.get("content-type")).toBe("application/json"); + expect(headers.get("x-hidden")).toBe("yes"); + return new Response("{}", { status: 200 }); + }; + const headers = { "content-type": "application/json" } as Record & { + [key: symbol]: unknown; + }; + Object.defineProperty(headers, "x-hidden", { + value: "yes", + enumerable: false, + }); + Object.defineProperty(headers, Symbol("sensitiveHeaders"), { + value: new Set(["content-type"]), + enumerable: false, + }); + + initializeDebugProxyCapture("test", settings, deps); + await fetchTarget.fetch("https://api.example.com/messages", { + method: "POST", + headers, + body: "{}", + }); + await new Promise((resolve) => setImmediate(resolve)); + finalizeDebugProxyCapture(settings, deps); + + const request = events.find((event) => event.kind === "request"); + expect(JSON.parse(String(request?.headersJson))).toMatchObject({ + "content-type": "application/json", + "x-hidden": "yes", + }); + expect(Object.getOwnPropertySymbols(headers)).toHaveLength(1); + }); + it("redacts sensitive request and response headers before persistence", async () => { initializeDebugProxyCapture("test", settings, deps); captureHttpExchange( diff --git a/src/proxy-capture/runtime.ts b/src/proxy-capture/runtime.ts index 4ae326a8118..11b50b46bb1 100644 --- a/src/proxy-capture/runtime.ts +++ b/src/proxy-capture/runtime.ts @@ -1,5 +1,6 @@ import { randomUUID } from "node:crypto"; import { URL } from "node:url"; +import { normalizeRequestInitHeadersForFetch } from "../infra/fetch-headers.js"; import { resolveDebugProxySettings, type DebugProxySettings } from "./env.js"; import { closeDebugProxyCaptureStore, @@ -174,8 +175,9 @@ function installDebugProxyGlobalFetchPatch( fetchTarget[DEBUG_PROXY_FETCH_PATCH_KEY] = { originalFetch }; fetchTarget.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { const url = resolveUrlString(input); + const normalizedInit = normalizeRequestInitHeadersForFetch(init); try { - const response = await originalFetch(input, init); + const response = await originalFetch(input, normalizedInit); if (url && /^https?:/i.test(url)) { captureHttpExchange( { @@ -184,17 +186,18 @@ function installDebugProxyGlobalFetchPatch( (typeof Request !== "undefined" && input instanceof Request ? input.method : undefined) ?? - init?.method ?? + normalizedInit?.method ?? "GET", requestHeaders: (typeof Request !== "undefined" && input instanceof Request ? input.headers - : undefined) ?? (init?.headers as Headers | Record | undefined), + : undefined) ?? + (normalizedInit?.headers as Headers | Record | undefined), requestBody: (typeof Request !== "undefined" && input instanceof Request ? (input as Request & { body?: BodyInit | null }).body : undefined) ?? - (init as (RequestInit & { body?: BodyInit | null }) | undefined)?.body ?? + (normalizedInit as (RequestInit & { body?: BodyInit | null }) | undefined)?.body ?? null, response, transport: "http", @@ -225,7 +228,7 @@ function installDebugProxyGlobalFetchPatch( (typeof Request !== "undefined" && input instanceof Request ? input.method : undefined) ?? - init?.method ?? + normalizedInit?.method ?? "GET", host: parsed.host, path: `${parsed.pathname}${parsed.search}`,