Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions src/__tests__/recorder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<void>((resolve) => echoServer.close(() => resolve()));
});
Expand Down
27 changes: 22 additions & 5 deletions src/recorder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<string, string> = {};
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;
}
}
Expand Down