From b8710a72c1776372889280c599a3f89cf7675383 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 20 Aug 2026 12:53:51 +0900 Subject: [PATCH] fix(auth): key admission-bearer substitution on transport, not provider name An admission bearer is one of OUR secrets. The contract in src/codex/auth-context.ts is that it is either replaced with the stored main credential or the request fails before any I/O -- it must never reach an upstream. #2132 correctly stopped requiring a ChatGPT credential for routed providers, but it asked the question by provider NAME: codexAccountMode comes from providerCodexAccountMode, which special-cases the id "openai". The passthrough adapter asks the same question by TRANSPORT: isCanonicalOpenAiForwardProvider reads adapter, auth mode, and base URL, and when it says yes it copies the caller's Authorization header upstream. A provider row named anything else, pointed at the canonical ChatGPT backend with authMode "forward", satisfies the adapter and fails the name check. Substitution was skipped and the admission secret was forwarded to ChatGPT. Reproduced through the real adapter: URL: https://chatgpt.com/backend-api/codex/responses AUTH: Bearer ocx_data_this_is_our_proxy_key Substitution now consults the same authority the adapter does, so the transport that actually carries the header is what decides. A key-authenticated routed provider is still not canonical-forward, so the install #2132 was filed for keeps working without a ChatGPT login. The two new tests assert the invariant rather than the implementation: an admission bearer never reaches the wire for a canonical transport, whatever the row is called. --- src/server/responses/core.ts | 14 +++- .../bearer-admission-routed-provider.test.ts | 70 +++++++++++++++++++ 2 files changed, 82 insertions(+), 2 deletions(-) diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 121fa93fe3..def443de65 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -1165,9 +1165,19 @@ async function resolveResponsesCodexAuth( // and can consume that credential; a key-authenticated routed provider carries its own and // never touches it. Keying on the caller alone made an install that deliberately never // logged into ChatGPT fail every routed request with "No usable Codex main credential". - // `codexAccountMode` is set only for the native openai row, which is exactly that test. + // + // But ask that question the way the ADAPTER asks it. `codexAccountMode` is derived from the + // provider NAME (`providerCodexAccountMode`), while the passthrough adapter decides whether + // to forward caller credentials from the TRANSPORT — adapter, auth mode, and base URL + // (`isCanonicalOpenAiForwardProvider`). A row the operator named anything other than + // `openai`, pointed at the canonical ChatGPT backend with `authMode: "forward"`, satisfies + // the adapter's test and fails this one, so substitution was skipped and the adapter then + // forwarded our own admission secret upstream. Two predicates answering one question is the + // bug; the transport is the authority, because the transport is what actually carries the + // header. A key-authenticated routed provider is still not canonical-forward, so #2132's + // no-ChatGPT-login install keeps working. const substituteMainCredential = options.admission?.source === "bearer" - && route.codexAccountMode !== undefined; + && (route.codexAccountMode !== undefined || isCanonicalOpenAiForwardProvider(route.provider)); if (route.codexAccountMode === "direct" && !substituteMainCredential) { validateForwardAdmissionCredential(req.headers, config); } diff --git a/tests/bearer-admission-routed-provider.test.ts b/tests/bearer-admission-routed-provider.test.ts index 2baa300393..be4449c144 100644 --- a/tests/bearer-admission-routed-provider.test.ts +++ b/tests/bearer-admission-routed-provider.test.ts @@ -186,3 +186,73 @@ describe("#2132 bearer admission does not require a ChatGPT credential for route }); }); +/** + * The predicate above must be keyed on TRANSPORT, not on the provider's name. + * + * `codexAccountMode` comes from `providerCodexAccountMode`, which special-cases the id + * `openai`. The passthrough adapter decides whether it may forward caller credentials from + * `isCanonicalOpenAiForwardProvider` — adapter, auth mode, and base URL. A row the operator + * named anything else, pointed at the canonical ChatGPT backend, satisfies the adapter's test + * and fails the name-based one. Substitution was therefore skipped and the adapter forwarded + * our own admission secret to ChatGPT. + * + * These assert the invariant rather than the implementation: an admission bearer must never + * reach the wire, whatever the row is called. + */ +describe("an admission bearer never reaches a canonical ChatGPT transport, whatever the row is named", () => { + function customNamedCanonicalConfig(): OcxConfig { + const base = mixedConfig(); + return { + ...base, + defaultProvider: "mirror", + providers: { + ...base.providers, + // Same adapter, same authMode, same canonical base URL as the `openai` row above. + // Only the name differs — and the name is not what carries the header upstream. + mirror: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + defaultModel: "gpt-5.6-luna", + }, + }, + } as OcxConfig; + } + + test("with no stored credential it fails closed instead of forwarding our secret", async () => { + saveConfig(customNamedCanonicalConfig()); + writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ tokens: {} })); + + const server = startServer(0); + try { + const response = await postResponses(server.url, "mirror/gpt-5.6-luna"); + + // Fail-before-I/O is the contract (src/codex/auth-context.ts): the only two acceptable + // outcomes for an admission bearer are replaced-with-stored-main, or refused. Reaching + // upstream at all with our secret in hand is the failure this pins. + expect(nativeAuth.join("|")).not.toContain(ADMISSION_SECRET); + expect(response.status).not.toBe(200); + } finally { + await server.stop(true); + } + }); + + test("with a stored credential the stored one is what goes upstream", async () => { + saveConfig(customNamedCanonicalConfig()); + const stored = liveJwt(); + writeFileSync( + join(codexHome, "auth.json"), + JSON.stringify({ tokens: { access_token: stored, account_id: "stored_main_acc" } }), + ); + + const server = startServer(0); + try { + await postResponses(server.url, "mirror/gpt-5.6-luna"); + + expect(nativeAuth.join("|")).not.toContain(ADMISSION_SECRET); + for (const sent of nativeAuth) expect(sent).toBe(`Bearer ${stored}`); + } finally { + await server.stop(true); + } + }); +});