From 6ccfc703f08d6835507b617fe11c29bdeb80ad3b Mon Sep 17 00:00:00 2001 From: 0xPuncker <22941237+0xPuncker@users.noreply.github.com> Date: Wed, 24 Jun 2026 17:02:56 -0300 Subject: [PATCH] fix(openrouter): stop leaking Anthropic auth headers to fallback providers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buildProviderHeaders forwarded all of Claude Code's incoming headers to OpenRouter/Z.AI except 4 hop-by-hop ones, including x-api-key. OpenRouter treats a stray x-api-key as a restricted data-policy request and returns '404: No endpoints available matching your guardrail restrictions' — so the OpenRouter fallback was dead for every real (Claude Code) request, even though the model slug and endpoint were correct. Strip Anthropic-specific routing/auth headers (x-api-key, anthropic-version, anthropic-beta, anthropic-dangerous-direct-browser-access) and content-length (recomputed after cleanBody) from the cross-provider passthrough. Verified end-to-end: OpenRouter now returns 200 with full Opus via ~anthropic/claude-opus-latest. --- src/index.ts | 37 +++++++++++++++++++++---------------- tests/proxy.test.js | 19 ++++++++++++++++++- 2 files changed, 39 insertions(+), 17 deletions(-) diff --git a/src/index.ts b/src/index.ts index 33b15b9..b640acc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1561,32 +1561,37 @@ export class ClaudeCodeProxy { * Build request headers for a specific provider */ private buildProviderHeaders(provider: 'anthropic' | 'zai' | 'openrouter', reqHeaders: Record): Record { - if (provider === 'zai') { - const headers: Record = { - host: new URL(this.config.zai.baseUrl).host, - authorization: `Bearer ${this.config.zai.apiKey}`, - }; - // Copy allowed headers + // Headers that must NOT be forwarded to a non-Anthropic upstream. Beyond the + // hop-by-hop/auth headers, Anthropic-specific routing headers — especially + // `x-api-key` — make OpenRouter route the request into a restricted data-policy + // path and return `404: No endpoints available matching your guardrail + // restrictions`. `content-length` is recomputed after cleanBody, so drop the + // client's value too. + const blockedForwardHeaders = [ + "authorization", "transfer-encoding", "connection", "host", "content-length", + "x-api-key", "anthropic-version", "anthropic-beta", "anthropic-dangerous-direct-browser-access", + ]; + const copyAllowed = (headers: Record) => { for (const [key, value] of Object.entries(reqHeaders)) { - if (!["authorization", "transfer-encoding", "connection", "host"].includes(key)) { + if (!blockedForwardHeaders.includes(key.toLowerCase())) { headers[key] = value; } } return headers; + }; + + if (provider === 'zai') { + return copyAllowed({ + host: new URL(this.config.zai.baseUrl).host, + authorization: `Bearer ${this.config.zai.apiKey}`, + }); } else if (provider === 'openrouter') { - const headers: Record = { + return copyAllowed({ host: new URL(this.config.openrouter.baseUrl).host, authorization: `Bearer ${this.config.openrouter.apiKey}`, "HTTP-Referer": "https://claude.ai/code", "X-Title": "Claude Code", - }; - // Copy allowed headers - for (const [key, value] of Object.entries(reqHeaders)) { - if (!["authorization", "transfer-encoding", "connection", "host"].includes(key)) { - headers[key] = value; - } - } - return headers; + }); } else { // Anthropic headers return this.cleanHeaders(reqHeaders); diff --git a/tests/proxy.test.js b/tests/proxy.test.js index 0e50c3d..b8ca48c 100644 --- a/tests/proxy.test.js +++ b/tests/proxy.test.js @@ -557,10 +557,12 @@ describe("Claude Code Proxy provider request normalization", () => { const proxy = createProxy(); let capturedUrl = ""; let capturedBody = ""; + let capturedHeaders = {}; proxy.httpRequest = async (url, options) => { capturedUrl = url; capturedBody = String(options.body); + capturedHeaders = options.headers; return { status: 200, headers: options.headers, @@ -577,7 +579,14 @@ describe("Claude Code Proxy provider request normalization", () => { metadata: { source: "test" }, extra_field: "should-be-stripped", }), - { "content-type": "application/json" }, + { + "content-type": "application/json", + // Claude Code always sends these Anthropic-specific headers; forwarding + // x-api-key to OpenRouter triggers a guardrail 404. + "x-api-key": "sk-ant-should-not-leak", + "anthropic-version": "2023-06-01", + "anthropic-beta": "claude-code-20250219", + }, "/v1/messages?beta=true", "POST", ); @@ -588,6 +597,14 @@ describe("Claude Code Proxy provider request normalization", () => { assert.equal(parsedBody.model, "~anthropic/claude-sonnet-latest"); assert.equal(parsedBody.extra_field, undefined); assert.deepEqual(parsedBody.metadata, { source: "test" }); + + // Anthropic-specific headers must NOT reach OpenRouter (cause guardrail 404). + assert.equal(capturedHeaders["x-api-key"], undefined); + assert.equal(capturedHeaders["anthropic-version"], undefined); + assert.equal(capturedHeaders["anthropic-beta"], undefined); + // OpenRouter's own auth + attribution headers are present. + assert.equal(capturedHeaders.authorization, "Bearer openrouter-test-key"); + assert.equal(capturedHeaders["HTTP-Referer"], "https://claude.ai/code"); }); it("normalizes OpenRouter message responses to strict Anthropic shape", async () => {