From 2415c3965f7e343bd0cd20e18882c9d18ee406dc Mon Sep 17 00:00:00 2001 From: luvs01 Date: Sat, 8 Aug 2026 10:19:32 +0900 Subject: [PATCH] fix(claude): isolate passthrough credentials --- .../src/content/docs/guides/claude-code.md | 7 ++- .../src/content/docs/ko/guides/claude-code.md | 5 +- .../content/docs/zh-cn/guides/claude-code.md | 5 +- src/server/claude-messages.ts | 22 +++++++-- tests/claude-native-passthrough.test.ts | 46 +++++++++++++++++++ 5 files changed, 77 insertions(+), 8 deletions(-) diff --git a/docs-site/src/content/docs/guides/claude-code.md b/docs-site/src/content/docs/guides/claude-code.md index e2d179883..c706e2d9d 100644 --- a/docs-site/src/content/docs/guides/claude-code.md +++ b/docs-site/src/content/docs/guides/claude-code.md @@ -156,13 +156,16 @@ caching and billing identity stay fully native, and routed models keep working i via the picker aliases. **Header handling:** hop-by-hop headers plus `host`, `content-length`, `accept-encoding`, -`x-opencodex-api-key`, and `origin` are stripped before forwarding. All other headers (including -`anthropic-beta` and `anthropic-version`) pass through. +`x-opencodex-api-key`, and `origin` are stripped before forwarding. Any `authorization` or +`x-api-key` value that matches an OpenCodex admission secret is also stripped; other end-to-end +headers (including `anthropic-beta` and `anthropic-version`) pass through. The passthrough fires when **all four** conditions are met: `nativePassthrough` is not `false`; the model begins with `claude` or `anthropic`; the bearer or `x-api-key` starts with `sk-ant-`; and alias/model-map resolution returns the same model unchanged. This also means the "claude.ai connectors are disabled" warning no longer appears with `ocx claude`. +On a non-loopback listener, native passthrough additionally requires proxy admission through +`x-opencodex-api-key`; `authorization` and `x-api-key` remain exclusively provider-owned. Disable with `claudeCode.nativePassthrough: false`; point elsewhere with `claudeCode.anthropicBaseUrl`. diff --git a/docs-site/src/content/docs/ko/guides/claude-code.md b/docs-site/src/content/docs/ko/guides/claude-code.md index 665c350b0..a2a00b266 100644 --- a/docs-site/src/content/docs/ko/guides/claude-code.md +++ b/docs-site/src/content/docs/ko/guides/claude-code.md @@ -75,13 +75,16 @@ macOS의 자동 연결(`claudeCode.systemEnv`)도 같은 방식으로 판단하 네이티브 상태로 유지되고, 같은 세션에서 선택기 별칭을 써서 라우팅 모델도 계속 사용할 수 있어요. **헤더 처리:** hop-by-hop 헤더와 `host`, `content-length`, `accept-encoding`, -`x-opencodex-api-key`, `origin`은 전달 전에 제거해요. 그 밖의 헤더(`anthropic-beta`, +`x-opencodex-api-key`, `origin`은 전달 전에 제거해요. OpenCodex 입장 비밀과 일치하는 +`authorization` 또는 `x-api-key` 값도 제거하고, 그 밖의 종단 간 헤더(`anthropic-beta`, `anthropic-version` 포함)는 그대로 전달해요. 다음 네 조건을 **모두** 충족하면 패스스루가 작동해요. `nativePassthrough`가 `false`가 아니고, 모델 이름이 `claude` 또는 `anthropic`으로 시작하며, bearer 또는 `x-api-key`가 `sk-ant-`로 시작하고, 별칭/모델 맵 해석 결과가 변경되지 않은 같은 모델이어야 해요. 그래서 `ocx claude`를 사용할 때 "claude.ai connectors are disabled" 경고도 더 이상 나타나지 않아요. +루프백이 아닌 리스너에서는 `x-opencodex-api-key`를 통한 프록시 인증도 필요하며, +`authorization`과 `x-api-key`는 제공자 자격 증명 전용이에요. `claudeCode.nativePassthrough: false`로 끌 수 있고, `claudeCode.anthropicBaseUrl`로 다른 주소를 지정할 수 있어요. diff --git a/docs-site/src/content/docs/zh-cn/guides/claude-code.md b/docs-site/src/content/docs/zh-cn/guides/claude-code.md index 31410c921..ffb34d2ba 100644 --- a/docs-site/src/content/docs/zh-cn/guides/claude-code.md +++ b/docs-site/src/content/docs/zh-cn/guides/claude-code.md @@ -50,13 +50,16 @@ macOS;在其他平台上,请使用 `ocx claude`。 而已路由模型仍可在同一会话中通过选择器别名使用。 **请求头处理:**转发前会移除逐跳请求头以及 `host`、`content-length`、`accept-encoding`、 -`x-opencodex-api-key` 和 `origin`。其他所有请求头(包括 `anthropic-beta` 和 +`x-opencodex-api-key` 和 `origin`。与 OpenCodex 准入密钥匹配的 `authorization` 或 +`x-api-key` 值也会被移除;其他端到端请求头(包括 `anthropic-beta` 和 `anthropic-version`)都会透传。 只有同时满足以下**四个**条件时才会触发透传:`nativePassthrough` 不为 `false`;模型以 `claude` 或 `anthropic` 开头;bearer 或 `x-api-key` 以 `sk-ant-` 开头;并且别名/模型映射 解析后返回的模型保持不变。这也意味着使用 `ocx claude` 时不再出现 “claude.ai connectors are disabled”警告。 +在非回环监听器上,原生透传还要求通过 `x-opencodex-api-key` 完成代理准入; +`authorization` 和 `x-api-key` 仅用于上游提供商凭据。 可以设置 `claudeCode.nativePassthrough: false` 来禁用;也可以通过 `claudeCode.anthropicBaseUrl` 指向其他位置。 diff --git a/src/server/claude-messages.ts b/src/server/claude-messages.ts index 875368ccc..277a31fd2 100644 --- a/src/server/claude-messages.ts +++ b/src/server/claude-messages.ts @@ -28,6 +28,7 @@ import { estimateTokens } from "../lib/token-estimate"; import { NoEligiblePolicyCandidateError, routeModel } from "../router"; import { evidenceFromBody } from "../routing/request-evidence"; import { resolveWireProtocolOverride } from "./adapter-resolve"; +import { isApiAuthRequired, isDataPlaneAdmissionSecret, isProxyAdmissionSecret } from "./auth-cors"; import type { OcxConfig } from "../types"; import { readJsonRequestBody } from "./request-decompress"; import { addFinalRequestLog, httpStatusForTerminalStatus, recordFirstOutput, type RequestLogContext, type RequestLogEntry } from "./request-log"; @@ -94,16 +95,23 @@ const PASSTHROUGH_STRIP_HEADERS = new Set([ "accept-encoding", "x-opencodex-api-key", "origin", ]); -function hasAnthropicNativeCredential(req: Request): boolean { +function hasAnthropicNativeCredential(req: Request, config: OcxConfig): boolean { const bearer = req.headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim() ?? ""; const apiKey = req.headers.get("x-api-key")?.trim() ?? ""; - return bearer.startsWith("sk-ant-") || apiKey.startsWith("sk-ant-"); + return (bearer.startsWith("sk-ant-") && !isProxyAdmissionSecret(bearer, config)) + || (apiKey.startsWith("sk-ant-") && !isProxyAdmissionSecret(apiKey, config)); } function wantsNativePassthrough(req: Request, config: OcxConfig, model: unknown): model is string { if (config.claudeCode?.nativePassthrough === false) return false; if (typeof model !== "string" || !/^(claude|anthropic)/i.test(model)) return false; - if (!hasAnthropicNativeCredential(req)) return false; + // On exposed listeners, keep proxy admission on its dedicated header so the + // provider-owned Authorization/X-Api-Key credentials are never ambiguous. + if (isApiAuthRequired(config)) { + const admission = req.headers.get("x-opencodex-api-key")?.trim() ?? ""; + if (!isDataPlaneAdmissionSecret(admission, config)) return false; + } + if (!hasAnthropicNativeCredential(req, config)) return false; // An alias or modelMap hit means the user asked for a ROUTED model: translate instead. return resolveInboundModel(model, config.claudeCode) === model; } @@ -324,7 +332,13 @@ async function anthropicNativePassthrough( } const headers = new Headers(); req.headers.forEach((value, name) => { - if (!PASSTHROUGH_STRIP_HEADERS.has(name.toLowerCase())) headers.set(name, value); + const lowerName = name.toLowerCase(); + if (PASSTHROUGH_STRIP_HEADERS.has(lowerName)) return; + const credential = lowerName === "authorization" + ? value.replace(/^Bearer\s+/i, "").trim() + : lowerName === "x-api-key" ? value.trim() : ""; + if (credential && isProxyAdmissionSecret(credential, config)) return; + headers.set(name, value); }); headers.set("content-type", "application/json"); diff --git a/tests/claude-native-passthrough.test.ts b/tests/claude-native-passthrough.test.ts index f64c4c948..19b95f837 100644 --- a/tests/claude-native-passthrough.test.ts +++ b/tests/claude-native-passthrough.test.ts @@ -205,6 +205,52 @@ test("count_tokens passes through with native credentials", async () => { } }); +test("native passthrough never forwards proxy admission credentials", async () => { + const previousToken = process.env.OPENCODEX_API_AUTH_TOKEN; + process.env.OPENCODEX_API_AUTH_TOKEN = "sk-ant-api03-key"; + const captured: Captured[] = []; + const upstream = mockAnthropicUpstream(captured); + saveConfig({ ...cfg(upstream.url.toString().replace(/\/$/, "")), hostname: "0.0.0.0" }); + const server = startServer(0); + const url = new URL("/v1/messages", server.url); + url.hostname = "127.0.0.1"; + try { + const ambiguous = await globalThis.fetch(url, { + method: "POST", + headers: { + "content-type": "application/json", + "authorization": "Bearer sk-ant-api03-key", + "x-api-key": "sk-ant-oat01-tst", + }, + body: JSON.stringify(claudeBody()), + }); + expect(ambiguous.status).not.toBe(200); + expect(captured).toHaveLength(0); + + const dedicated = await globalThis.fetch(url, { + method: "POST", + headers: { + "content-type": "application/json", + "x-opencodex-api-key": "sk-ant-api03-key", + "authorization": "Bearer sk-ant-oat01-tst", + "x-api-key": "sk-ant-api03-key", + }, + body: JSON.stringify(claudeBody()), + }); + expect(dedicated.status).toBe(200); + await dedicated.text(); + expect(captured).toHaveLength(1); + expect(captured[0].headers.get("authorization")).toBe("Bearer sk-ant-oat01-tst"); + expect(captured[0].headers.get("x-api-key")).toBeNull(); + expect(captured[0].headers.get("x-opencodex-api-key")).toBeNull(); + } finally { + await server.stop(true); + upstream.stop(true); + if (previousToken === undefined) delete process.env.OPENCODEX_API_AUTH_TOKEN; + else process.env.OPENCODEX_API_AUTH_TOKEN = previousToken; + } +}); + test("alias/mapped models and non-anthropic credentials do NOT pass through", async () => { const captured: Captured[] = []; const upstream = mockAnthropicUpstream(captured);