diff --git a/src/__tests__/recorder.test.ts b/src/__tests__/recorder.test.ts index f2ac2c09..7020ceb9 100644 --- a/src/__tests__/recorder.test.ts +++ b/src/__tests__/recorder.test.ts @@ -1673,9 +1673,9 @@ describe("recorder auth header handling", () => { expect(content).not.toContain("api-key"); }); - it("custom non-auth headers from client are NOT forwarded to upstream", async () => { - // We'll verify by checking that the upstream doesn't receive custom headers. - // Create a raw upstream that echoes back received headers. + it("all non-hop-by-hop headers from client are forwarded to upstream", async () => { + // Verify that provider-specific headers (e.g. anthropic-version) are forwarded, + // while hop-by-hop headers (host, connection, etc.) are stripped. let receivedHeaders: http.IncomingHttpHeaders = {}; const echoServer = http.createServer((req, res) => { receivedHeaders = req.headers; @@ -1705,15 +1705,15 @@ describe("recorder auth header handling", () => { }, { Authorization: "Bearer sk-test", - "X-Custom-Header": "should-not-forward", - "X-Request-Id": "req-123", + "X-Custom-Header": "custom-value", + "anthropic-version": "2023-06-01", }, ); - // Authorization is forwarded, custom headers are not + // All non-hop-by-hop headers are forwarded expect(receivedHeaders["authorization"]).toBe("Bearer sk-test"); - expect(receivedHeaders["x-custom-header"]).toBeUndefined(); - expect(receivedHeaders["x-request-id"]).toBeUndefined(); + expect(receivedHeaders["x-custom-header"]).toBe("custom-value"); + expect(receivedHeaders["anthropic-version"]).toBe("2023-06-01"); await new Promise((resolve) => echoServer.close(() => resolve())); }); diff --git a/src/recorder.ts b/src/recorder.ts index ef34c001..ea8db18b 100644 --- a/src/recorder.ts +++ b/src/recorder.ts @@ -16,6 +16,25 @@ import type { Logger } from "./logger.js"; import { collapseStreamingResponse } from "./stream-collapse.js"; import { writeErrorResponse } from "./sse-writer.js"; +/** Headers to strip when proxying — hop-by-hop (RFC 2616 §13.5.1) + client-set. */ +const STRIP_HEADERS = new Set([ + // Hop-by-hop (RFC 2616 §13.5.1) + "connection", + "keep-alive", + "transfer-encoding", + "te", + "trailer", + "upgrade", + "proxy-authorization", + "proxy-authenticate", + // Set by HTTP client from the target URL / body + "host", + "content-length", + // Not relevant for LLM APIs; avoid leaking or mismatched encoding + "cookie", + "accept-encoding", +]); + /** * Proxy an unmatched request to the real upstream provider, record the * response as a fixture on disk and in memory, then relay the response @@ -63,12 +82,10 @@ export async function proxyAndRecord( defaults.logger.warn(`NO FIXTURE MATCH — proxying to ${upstreamUrl}${pathname}`); - // Forward only safe headers — auth and content negotiation + // Forward all request headers except hop-by-hop and client-set ones. const forwardHeaders: Record = {}; - const headersToForward = ["authorization", "x-api-key", "api-key", "content-type", "accept"]; - for (const name of headersToForward) { - const val = req.headers[name]; - if (val !== undefined) { + for (const [name, val] of Object.entries(req.headers)) { + if (val !== undefined && !STRIP_HEADERS.has(name)) { forwardHeaders[name] = Array.isArray(val) ? val.join(", ") : val; } }