diff --git a/src/codex/auth-api.ts b/src/codex/auth-api.ts index ac1b81a09..145971352 100644 --- a/src/codex/auth-api.ts +++ b/src/codex/auth-api.ts @@ -104,7 +104,8 @@ import type { CodexAccount, CodexAccountCredentials, OcxConfig } from "../types" import type { CatalogDisposition } from "./convergence-types"; import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers"; import { providerCodexAccountMode } from "../providers/registry"; -import { readBoundedResponseBody } from "../lib/bounded-body"; +import { BOUNDED_BODY_MAX_BYTES, readBoundedResponseBody } from "../lib/bounded-body"; +import { cancelBodyOnAbort, signalWithTimeout } from "../lib/abort"; import { oauthAccountHealthFields, projectCodexAccountHealth, @@ -337,6 +338,43 @@ function safeResetCreditConsumeDto(input: unknown): { code: string } { return { code: typeof obj.code === "string" ? obj.code : "unknown" }; } +type ResetCreditJsonRead = + | { ok: true; value: unknown } + | { ok: false }; + +function cancelResponseBodyWithoutWaiting(body: ReadableStream | null): void { + if (!body) return; + try { + void body.cancel().catch(() => undefined); + } catch { + // Some stream implementations throw synchronously from cancel(). + } +} + +async function readResetCreditJson( + response: Response, + signal: AbortSignal, +): Promise { + const declaredLength = Number(response.headers.get("content-length")); + if (Number.isSafeInteger(declaredLength) + && declaredLength >= 0 + && declaredLength > BOUNDED_BODY_MAX_BYTES) { + cancelResponseBodyWithoutWaiting(response.body); + return { ok: false }; + } + try { + const body = await readBoundedResponseBody(response, { + signal, + maxBytes: BOUNDED_BODY_MAX_BYTES, + fatalUtf8: true, + }); + if (!body.displaySafe || body.truncated || !body.text.trim()) return { ok: false }; + return { ok: true, value: JSON.parse(body.text) as unknown }; + } catch { + return { ok: false }; + } +} + export function isUnverifiedCodexImportEnabled(): boolean { return process.env[MANUAL_IMPORT_ENV] === "1"; } @@ -1682,21 +1720,44 @@ export async function handleCodexAuthAPI( try { const result = await withResetCreditAuth(getRuntimeConfig(config), accountId, async auth => { - const resp = await fetch( - "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits", - { - headers: { - Authorization: `Bearer ${auth.accessToken}`, - "ChatGPT-Account-Id": auth.chatgptAccountId, - }, - signal: AbortSignal.timeout(8000), - }, - ); - if (!resp.ok) { - await resp.body?.cancel().catch(() => {}); - return jsonResponse({ error: `Upstream error ${resp.status}` }, resp.status); + const linkedSignal = signalWithTimeout(8000, req.signal); + let detachBodyAbort = () => {}; + try { + let resp: Response; + try { + resp = await fetch( + "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits", + { + headers: { + Authorization: `Bearer ${auth.accessToken}`, + "ChatGPT-Account-Id": auth.chatgptAccountId, + }, + signal: linkedSignal.signal, + }, + ); + } catch (error) { + if (linkedSignal.signal.aborted) { + return jsonResponse({ error: "Invalid upstream reset-credit response" }, 502); + } + throw error; + } + // Own the response body before the bounded reader attaches. If the client + // disconnects in that narrow window, Bun otherwise tears down the native + // body off the awaited path and can report an unhandled rejection. + detachBodyAbort = cancelBodyOnAbort(resp.body, linkedSignal.signal); + if (!resp.ok) { + await resp.body?.cancel().catch(() => {}); + return jsonResponse({ error: `Upstream error ${resp.status}` }, resp.status); + } + const parsed = await readResetCreditJson(resp, linkedSignal.signal); + if (!parsed.ok) { + return jsonResponse({ error: "Invalid upstream reset-credit response" }, 502); + } + return jsonResponse(safeResetCreditsDto(parsed.value)); + } finally { + detachBodyAbort(); + linkedSignal.cleanup(); } - return jsonResponse(safeResetCreditsDto(await resp.json())); }); return result.ok ? result.value : result.response; } catch (e) { diff --git a/tests/codex-auth-api.test.ts b/tests/codex-auth-api.test.ts index f6d447a93..08e85f4a4 100644 --- a/tests/codex-auth-api.test.ts +++ b/tests/codex-auth-api.test.ts @@ -69,6 +69,7 @@ import { listOpenAiForwardSidecarCandidates, resolveFirstUsableOpenAiSidecar, } from "../src/providers/openai-sidecar"; +import { BOUNDED_BODY_MAX_BYTES } from "../src/lib/bounded-body"; const TEST_DIR = join(import.meta.dir, ".tmp-codex-auth-api-test"); const TEST_CODEX_HOME = join(TEST_DIR, "codex"); @@ -1958,6 +1959,176 @@ describe("codex-auth API", () => { } }); + test("reset-credit lookup rejects a declared oversized response before reading it", async () => { + const config = makeConfig(); + seedPoolAccount(config, { id: "credit-declared-cap", email: "declared@example.test" }); + let pulls = 0; + let markCancelled!: () => void; + const cancelled = new Promise(resolve => { markCancelled = resolve; }); + globalThis.fetch = (async () => new Response(new ReadableStream({ + pull() { pulls += 1; }, + cancel() { markCancelled(); }, + }, { highWaterMark: 0 }), { + headers: { "content-length": String(BOUNDED_BODY_MAX_BYTES + 1) }, + })) as typeof fetch; + + const req = new Request("http://localhost/api/codex-auth/reset-credits?accountId=credit-declared-cap"); + const resp = await handleCodexAuthAPI(req, new URL(req.url), config); + + expect(resp?.status).toBe(502); + expect(await resp?.json()).toEqual({ error: "Invalid upstream reset-credit response" }); + await cancelled; + expect(pulls).toBe(0); + }); + + test("reset-credit lookup cancels an undeclared response that crosses the byte cap", async () => { + const config = makeConfig(); + seedPoolAccount(config, { id: "credit-stream-cap", email: "stream@example.test" }); + let sent = false; + let markCancelled!: () => void; + const cancelled = new Promise(resolve => { markCancelled = resolve; }); + globalThis.fetch = (async () => new Response(new ReadableStream({ + pull(controller) { + if (!sent) { + sent = true; + controller.enqueue(new Uint8Array(BOUNDED_BODY_MAX_BYTES)); + return; + } + controller.enqueue(new Uint8Array([0x61])); + }, + cancel() { markCancelled(); }, + }))) as typeof fetch; + + const req = new Request("http://localhost/api/codex-auth/reset-credits?accountId=credit-stream-cap"); + const resp = await handleCodexAuthAPI(req, new URL(req.url), config); + + expect(resp?.status).toBe(502); + expect(await resp?.json()).toEqual({ error: "Invalid upstream reset-credit response" }); + await cancelled; + }); + + test("reset-credit lookup binds client cancellation to the upstream body", async () => { + const config = makeConfig(); + seedPoolAccount(config, { id: "credit-cancel", email: "cancel@example.test" }); + let started!: () => void; + const bodyStarted = new Promise(resolve => { started = resolve; }); + let markCancelled!: () => void; + const cancelled = new Promise(resolve => { markCancelled = resolve; }); + globalThis.fetch = (async () => new Response(new ReadableStream({ + pull() { started(); }, + cancel() { markCancelled(); }, + }))) as typeof fetch; + const controller = new AbortController(); + const req = new Request("http://localhost/api/codex-auth/reset-credits?accountId=credit-cancel", { + signal: controller.signal, + }); + + const pending = handleCodexAuthAPI(req, new URL(req.url), config); + await bodyStarted; + controller.abort(new DOMException("client disconnected", "AbortError")); + const resp = await pending; + + expect(resp?.status).toBe(502); + expect(await resp?.json()).toEqual({ error: "Invalid upstream reset-credit response" }); + await cancelled; + }); + + test("reset-credit lookup cancels a response when the client aborts before the reader attaches", async () => { + const config = makeConfig(); + seedPoolAccount(config, { id: "credit-pre-reader-cancel", email: "pre-reader@example.test" }); + const controller = new AbortController(); + let pulls = 0; + let markCancelled!: () => void; + const cancelled = new Promise(resolve => { markCancelled = resolve; }); + globalThis.fetch = (async () => { + controller.abort(new DOMException("client disconnected", "AbortError")); + return new Response(new ReadableStream({ + pull() { pulls += 1; }, + cancel() { markCancelled(); }, + }, { highWaterMark: 0 })); + }) as typeof fetch; + const req = new Request("http://localhost/api/codex-auth/reset-credits?accountId=credit-pre-reader-cancel", { + signal: controller.signal, + }); + + const resp = await handleCodexAuthAPI(req, new URL(req.url), config); + + expect(resp?.status).toBe(502); + expect(await resp?.json()).toEqual({ error: "Invalid upstream reset-credit response" }); + await cancelled; + expect(pulls).toBe(0); + }); + + test("reset-credit lookup sanitizes cancellation before upstream response headers", async () => { + const config = makeConfig(); + seedPoolAccount(config, { id: "credit-fetch-cancel", email: "fetch-cancel@example.test" }); + const controller = new AbortController(); + let markFetchStarted!: () => void; + const fetchStarted = new Promise(resolve => { markFetchStarted = resolve; }); + let upstreamSignal: AbortSignal | null = null; + globalThis.fetch = (async (_input, init) => { + upstreamSignal = init?.signal ?? null; + markFetchStarted(); + return await new Promise((_resolve, reject) => { + const rejectForAbort = () => reject(upstreamSignal?.reason); + if (upstreamSignal?.aborted) { + rejectForAbort(); + return; + } + upstreamSignal?.addEventListener("abort", rejectForAbort, { once: true }); + }); + }) as typeof fetch; + const req = new Request("http://localhost/api/codex-auth/reset-credits?accountId=credit-fetch-cancel", { + signal: controller.signal, + }); + + const pending = handleCodexAuthAPI(req, new URL(req.url), config); + await fetchStarted; + controller.abort(new Error("private reset-credit abort detail")); + const resp = await pending; + + expect(upstreamSignal?.aborted).toBe(true); + expect(resp?.status).toBe(502); + expect(await resp?.json()).toEqual({ error: "Invalid upstream reset-credit response" }); + }); + + test.each([ + { label: "invalid UTF-8", body: new Uint8Array([0xff]) }, + { label: "malformed JSON", body: "{" }, + ])("reset-credit lookup rejects $label without reflecting it", async ({ body }) => { + const config = makeConfig(); + seedPoolAccount(config, { id: "credit-invalid", email: "invalid@example.test" }); + globalThis.fetch = (async () => new Response(body)) as typeof fetch; + + const req = new Request("http://localhost/api/codex-auth/reset-credits?accountId=credit-invalid"); + const resp = await handleCodexAuthAPI(req, new URL(req.url), config); + + expect(resp?.status).toBe(502); + expect(await resp?.json()).toEqual({ error: "Invalid upstream reset-credit response" }); + }); + + test("reset-credit lookup returns only validated fields from a bounded response", async () => { + const config = makeConfig(); + seedPoolAccount(config, { id: "credit-fields", email: "fields@example.test" }); + globalThis.fetch = (async () => Response.json({ + credits: [ + { granted_at: "2026-01-01T00:00:00Z", expires_at: "2026-02-01T00:00:00Z", secret: "drop-me" }, + { granted_at: 123, expires_at: "invalid" }, + ], + rate_limit_reset_credits: { available_count: 1 }, + unexpected: "drop-me", + })) as typeof fetch; + + const req = new Request("http://localhost/api/codex-auth/reset-credits?accountId=credit-fields"); + const resp = await handleCodexAuthAPI(req, new URL(req.url), config); + + expect(resp?.status).toBe(200); + expect(await resp?.json()).toEqual({ + credits: [{ granted_at: "2026-01-01T00:00:00Z", expires_at: "2026-02-01T00:00:00Z" }], + available_count: 1, + }); + }); + test("reset-credit consume rejects invalid account ids before credential lookup", async () => { const req = new Request("http://localhost/api/codex-auth/reset-credits/consume", { method: "POST",