Skip to content
Closed
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
5 changes: 4 additions & 1 deletion src/codex/auth-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -576,11 +576,13 @@ export class CodexMainSubstitutionUnavailableError extends Error {
* Silently forwarding would be the leak validateForwardAdmissionCredential exists to prevent.
* - `main` with a dedicated-header caller keeps the existing intentional passthrough: the bearer
* there is the user's own ChatGPT credential, not ours.
* - `main` on a non-Codex route strips a bearer used for OpenCodex admission, because routed
* adapters may otherwise interpret that forwarded header as their own upstream credential.
*/
export function materializeCodexUpstreamAuth(
headers: Headers,
ctx: CodexAuthContext,
options: { substituteMainCredential?: boolean } = {},
options: { substituteMainCredential?: boolean; stripAuthorization?: boolean } = {},
): Headers {
const selected = new Headers();
for (const name of FORWARD_HEADERS) {
Expand All @@ -602,6 +604,7 @@ export function materializeCodexUpstreamAuth(
if (stored.chatgptAccountId) selected.set("chatgpt-account-id", stored.chatgptAccountId);
return selected;
}
if (options.stripAuthorization === true) selected.delete("authorization");
return selected;
}

Expand Down
10 changes: 9 additions & 1 deletion src/server/responses/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1201,10 +1201,18 @@ async function resolveResponsesCodexAuth(
response: formatErrorResponse(401, "authentication_error", "Selected Codex account needs reauthentication"),
};
}
// A bearer admission proves access with one of OpenCodex's own secrets. Routes that do not
// consume a stored Codex credential still need the caller's admission Authorization removed:
// some adapters intentionally use forwarded Authorization as their provider credential.
// Dedicated-header admission remains unchanged because its Authorization belongs upstream.
const stripAuthorization = options.admission?.source === "bearer" && !substituteMainCredential;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Inspect Authorization before preserving it

When a request supplies both a valid x-opencodex-api-key and Authorization: Bearer <another proxy key>, resolveResponsesApiAuth deliberately records the admission source as dedicated, so this condition is false and the proxy bearer remains in the forwarded headers. A routed Cursor provider without its own apiKey, for example, consumes that forwarded value as its upstream token in src/adapters/cursor/live-transport.ts:159-164, exposing an OpenCodex secret despite this fix. Determine whether the actual Authorization value is a proxy admission secret (for example via isProxyAdmissionSecret) instead of relying solely on the winning admission source, while preserving genuinely foreign upstream bearers.

AGENTS.md reference: AGENTS.md:L266-L272

Useful? React with 👍 / 👎.

return {
ok: true,
authCtx,
headers: materializeCodexUpstreamAuth(req.headers, authCtx, { substituteMainCredential }),
headers: materializeCodexUpstreamAuth(req.headers, authCtx, {
substituteMainCredential,
stripAuthorization,
}),
};
} catch (err) {
if (err instanceof CodexAccountCooldownError) {
Expand Down
15 changes: 15 additions & 0 deletions tests/codex-auth-context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -787,6 +787,21 @@ describe("Codex auth context", () => {
);
expect(headers.get("authorization")).toBe("Bearer user_chatgpt_token");
});

test("a routed-provider admission bearer is removed while other headers remain", () => {
const headers = materializeCodexUpstreamAuth(
new Headers({
authorization: "Bearer ocx_data_localsecret",
"openai-beta": "responses=experimental",
}),
{ kind: "main", accountId: null },
{ stripAuthorization: true },
);

expect(headers.has("authorization")).toBe(false);
expect(headers.get("openai-beta")).toBe("responses=experimental");
});

test("selected pool headers replace inbound main auth", () => {
const headers = headersForCodexAuthContext(
new Headers({ authorization: "Bearer main_token", "chatgpt-account-id": "main_acc", "openai-beta": "responses=experimental" }),
Expand Down
Loading