Skip to content

Commit a89fe70

Browse files
Pandah97steipete
andauthored
fix(openai): bound Codex OAuth token response body reads with readResponseWithLimit (#99479)
* fix(openai): bound Codex OAuth token response body reads with readResponseWithLimit Replace unbounded response.arrayBuffer() in postTokenForm with readResponseWithLimit using a 1 MiB cap to prevent OOM from oversized token endpoint responses. Add real node:http loopback server tests. * fix(openai): wrap readResponseWithLimit result in Uint8Array for TS BodyInit compat - Fixes TS2345: Buffer<ArrayBufferLike> not assignable to BodyInit - Resolves check-prod-types, check-test-types, and check-additional-extension-package-boundary CI failures Ref. #99479 * test(openai): verify OAuth response release --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
1 parent d09e2e7 commit a89fe70

2 files changed

Lines changed: 113 additions & 2 deletions

File tree

extensions/openai/openai-chatgpt-oauth-flow.runtime.test.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
// Openai tests cover openai chatgpt oauth flow plugin behavior.
2+
import { createServer, type Server } from "node:http";
23
import { afterEach, describe, expect, it, vi } from "vitest";
34

45
const ssrfMocks = vi.hoisted(() => ({
@@ -175,3 +176,102 @@ describe("OpenAI Codex OAuth flow", () => {
175176
});
176177
});
177178
});
179+
180+
async function listenLoopbackServer(server: Server): Promise<number> {
181+
return await new Promise<number>((resolve, reject) => {
182+
server.once("error", reject);
183+
server.listen(0, "127.0.0.1", () => {
184+
server.off("error", reject);
185+
const address = server.address();
186+
if (!address || typeof address === "string") {
187+
reject(new Error("expected loopback TCP address"));
188+
return;
189+
}
190+
resolve(address.port);
191+
});
192+
});
193+
}
194+
195+
async function closeServer(server: Server): Promise<void> {
196+
await new Promise<void>((resolve) => {
197+
server.close(() => resolve());
198+
});
199+
}
200+
201+
describe("OpenAI Codex OAuth bounded token response reads", () => {
202+
it("reads under-cap token exchange responses from a real loopback HTTP server", async () => {
203+
const validPayload = {
204+
access_token: "access-token-loopback",
205+
refresh_token: "refresh-token-loopback",
206+
expires_in: 3600,
207+
};
208+
const server = createServer((_req, res) => {
209+
res.writeHead(200, { "content-type": "application/json" });
210+
res.end(JSON.stringify(validPayload));
211+
});
212+
const port = await listenLoopbackServer(server);
213+
const release = vi.fn(async () => undefined);
214+
215+
try {
216+
ssrfMocks.fetchWithSsrFGuard.mockImplementation(async ({ init, signal }) => {
217+
const response = await globalThis.fetch(`http://127.0.0.1:${port}`, {
218+
...init,
219+
signal,
220+
});
221+
return { response, release };
222+
});
223+
224+
const result = await testing.exchangeAuthorizationCode(
225+
"code-loopback",
226+
"verifier-loopback",
227+
"http://localhost:1455/auth/callback",
228+
{ timeoutMs: 5000 },
229+
);
230+
231+
expect(result).toMatchObject({
232+
type: "success",
233+
access: "access-token-loopback",
234+
refresh: "refresh-token-loopback",
235+
});
236+
expect(
237+
(result as { type: "success"; access: string; refresh: string; expires: number }).expires,
238+
).toBeGreaterThan(0);
239+
expect(release).toHaveBeenCalledOnce();
240+
} finally {
241+
await closeServer(server);
242+
}
243+
});
244+
245+
it("rejects oversized token exchange responses from a real loopback HTTP server", async () => {
246+
const oversizedPayload = "o".repeat(2 * 1024 * 1024); // 2 MiB > 1 MiB cap
247+
const server = createServer((_req, res) => {
248+
res.writeHead(200, { "content-type": "application/json" });
249+
res.end(oversizedPayload);
250+
});
251+
const port = await listenLoopbackServer(server);
252+
const release = vi.fn(async () => undefined);
253+
254+
try {
255+
ssrfMocks.fetchWithSsrFGuard.mockImplementation(async ({ init, signal }) => {
256+
const response = await globalThis.fetch(`http://127.0.0.1:${port}`, {
257+
...init,
258+
signal,
259+
});
260+
return { response, release };
261+
});
262+
263+
const result = await testing.exchangeAuthorizationCode(
264+
"code-loopback",
265+
"verifier-loopback",
266+
"http://localhost:1455/auth/callback",
267+
{ timeoutMs: 5000 },
268+
);
269+
270+
expect(result).toMatchObject({ type: "failed" });
271+
expect((result as { type: "failed"; message: string }).message).toContain("too large");
272+
expect(release).toHaveBeenCalledOnce();
273+
} finally {
274+
await closeServer(server);
275+
}
276+
});
277+
});

extensions/openai/openai-chatgpt-oauth-flow.runtime.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
resolveOAuthTokenExpiresAt,
1212
resolveOAuthTokenLifetimeMs,
1313
} from "openclaw/plugin-sdk/provider-oauth-runtime";
14+
import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
1415
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
1516
import { resolveCodexAuthIdentity } from "./openai-chatgpt-auth-identity.js";
1617
import {
@@ -39,6 +40,7 @@ const REDIRECT_URI = resolveRedirectUri(CALLBACK_HOST);
3940
const MANUAL_PROMPT_FALLBACK_MS = 15_000;
4041
const TOKEN_REQUEST_TIMEOUT_MS = 30_000;
4142
const SCOPE = "openid profile email offline_access";
43+
const OAUTH_TOKEN_RESPONSE_BODY_LIMIT_BYTES = 1 * 1024 * 1024;
4244

4345
type TokenSuccess = { type: "success"; access: string; refresh: string; expires: number };
4446
type TokenFailure = { type: "failed"; message: string; status?: number };
@@ -178,8 +180,17 @@ async function postTokenForm(
178180
auditContext: "openai-chatgpt-oauth-token",
179181
});
180182
try {
181-
const responseBody = await response.arrayBuffer();
182-
return new Response(responseBody, {
183+
const responseBody = await readResponseWithLimit(
184+
response,
185+
OAUTH_TOKEN_RESPONSE_BODY_LIMIT_BYTES,
186+
{
187+
onOverflow: ({ size, maxBytes }) =>
188+
new Error(
189+
`OpenAI Codex OAuth token response body too large: ${size} bytes (limit: ${maxBytes} bytes)`,
190+
),
191+
},
192+
);
193+
return new Response(new Uint8Array(responseBody), {
183194
status: response.status,
184195
statusText: response.statusText,
185196
headers: response.headers,

0 commit comments

Comments
 (0)