Skip to content
Merged
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
25 changes: 25 additions & 0 deletions src/content-type-repair.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
5 changes: 3 additions & 2 deletions src/content-type-repair.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
16 changes: 16 additions & 0 deletions src/oauth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 8 additions & 2 deletions src/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
);
}

Expand Down
Loading