From 3a9dee25893b6ad22345fd5bfde3741b84f55a82 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 26 Sep 2026 19:12:21 -0700 Subject: [PATCH] fix: drop unsafe account id claims and repair content-type on query URLs An account id claim carrying CR, LF or NUL is treated as absent, so the chatgpt-account-id header is omitted. Content-type repair matches on the URL pathname so query strings still repair, and treats an empty content-type as missing. --- src/content-type-repair.test.ts | 25 +++++++++++++++++++++++++ src/content-type-repair.ts | 5 +++-- src/oauth.test.ts | 16 ++++++++++++++++ src/oauth.ts | 10 ++++++++-- 4 files changed, 52 insertions(+), 4 deletions(-) diff --git a/src/content-type-repair.test.ts b/src/content-type-repair.test.ts index 667cbba..98819f3 100644 --- a/src/content-type-repair.test.ts +++ b/src/content-type-repair.test.ts @@ -68,4 +68,29 @@ describe("Codex content-type repair — missing content-type on 2xx SSE response ); expect(otherUrlResponse.headers.get("content-type")).toBeNull(); }); + + test("repairs when the URL carries a query string", async () => { + const repaired = withCodexContentTypeRepair( + fetchReturning(new Response("data: {}\n\n", { status: 200 })), + ); + const response = await repaired(`${url}?client=x`, { + headers: { accept: "text/event-stream" }, + }); + expect(response.headers.get("content-type")).toBe("text/event-stream"); + }); + + test("treats an empty content-type as missing", async () => { + const repaired = withCodexContentTypeRepair( + fetchReturning( + new Response("data: {}\n\n", { + status: 200, + headers: { "content-type": "" }, + }), + ), + ); + const response = await repaired(url, { + headers: { accept: "text/event-stream" }, + }); + expect(response.headers.get("content-type")).toBe("text/event-stream"); + }); }); diff --git a/src/content-type-repair.ts b/src/content-type-repair.ts index cfdf22a..96d9247 100644 --- a/src/content-type-repair.ts +++ b/src/content-type-repair.ts @@ -54,9 +54,10 @@ function acceptedContentType( export function withCodexContentTypeRepair(fetchImpl: FetchLike): FetchLike { return async (input, init) => { const response = await fetchImpl(input, init); - if (!requestURL(input).endsWith(CODEX_RESPONSES_PATH)) return response; + if (!new URL(requestURL(input)).pathname.endsWith(CODEX_RESPONSES_PATH)) + return response; if (!response.ok) return response; - if (response.headers.get("content-type") !== null) return response; + if (response.headers.get("content-type")) return response; const declared = acceptedContentType(input, init); if (declared === null) return response; const headers = new Headers(response.headers); diff --git a/src/oauth.test.ts b/src/oauth.test.ts index 6f8b30a..ed46f7d 100644 --- a/src/oauth.test.ts +++ b/src/oauth.test.ts @@ -35,6 +35,22 @@ describe("Codex oauth — account id decoding", () => { accountIdFromIdToken(jwtWithPayload({ sub: "user-1" })), ).toBeUndefined(); }); + + test("treats a claim carrying CR, LF or NUL as absent", () => { + for (const bad of ["acc\r\nx-evil: 1", "acc\nx", "acc\u0000x"]) { + expect( + accountIdFromIdToken(jwtWithPayload({ chatgpt_account_id: bad })), + ).toBeUndefined(); + } + expect( + accountIdFromIdToken( + jwtWithPayload({ + chatgpt_account_id: "top\r\n", + "https://api.openai.com/auth": { chatgpt_account_id: "nested" }, + }), + ), + ).toBe("nested"); + }); }); // A refresh response frequently omits id_token entirely; without carrying diff --git a/src/oauth.ts b/src/oauth.ts index 16a3e9f..36f5c1f 100644 --- a/src/oauth.ts +++ b/src/oauth.ts @@ -43,6 +43,12 @@ const IdTokenClaims = type({ }, }); +// The claim becomes a request header value; CR, LF or NUL would split or +// truncate it, so such a claim is treated as absent. +function headerSafe(value: string | undefined): string | undefined { + return value === undefined || /[\r\n\0]/.test(value) ? undefined : value; +} + /** * Decodes the ChatGPT account id out of an `id_token` (a JWT). The claim * lives at `chatgpt_account_id` or nested under the @@ -68,8 +74,8 @@ export function accountIdFromIdToken( const parsed = IdTokenClaims(claims); if (parsed instanceof type.errors) return undefined; return ( - parsed.chatgpt_account_id ?? - parsed["https://api.openai.com/auth"]?.chatgpt_account_id + headerSafe(parsed.chatgpt_account_id) ?? + headerSafe(parsed["https://api.openai.com/auth"]?.chatgpt_account_id) ); }