diff --git a/extensions/openai/openai-chatgpt-oauth-flow.runtime.test.ts b/extensions/openai/openai-chatgpt-oauth-flow.runtime.test.ts index 5624f0bfa9fb..89ab9ffeb8be 100644 --- a/extensions/openai/openai-chatgpt-oauth-flow.runtime.test.ts +++ b/extensions/openai/openai-chatgpt-oauth-flow.runtime.test.ts @@ -1,4 +1,5 @@ // Openai tests cover openai chatgpt oauth flow plugin behavior. +import { createServer, type Server } from "node:http"; import { afterEach, describe, expect, it, vi } from "vitest"; const ssrfMocks = vi.hoisted(() => ({ @@ -175,3 +176,102 @@ describe("OpenAI Codex OAuth flow", () => { }); }); }); + +async function listenLoopbackServer(server: Server): Promise { + return await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + server.off("error", reject); + const address = server.address(); + if (!address || typeof address === "string") { + reject(new Error("expected loopback TCP address")); + return; + } + resolve(address.port); + }); + }); +} + +async function closeServer(server: Server): Promise { + await new Promise((resolve) => { + server.close(() => resolve()); + }); +} + +describe("OpenAI Codex OAuth bounded token response reads", () => { + it("reads under-cap token exchange responses from a real loopback HTTP server", async () => { + const validPayload = { + access_token: "access-token-loopback", + refresh_token: "refresh-token-loopback", + expires_in: 3600, + }; + const server = createServer((_req, res) => { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify(validPayload)); + }); + const port = await listenLoopbackServer(server); + const release = vi.fn(async () => undefined); + + try { + ssrfMocks.fetchWithSsrFGuard.mockImplementation(async ({ init, signal }) => { + const response = await globalThis.fetch(`http://127.0.0.1:${port}`, { + ...init, + signal, + }); + return { response, release }; + }); + + const result = await testing.exchangeAuthorizationCode( + "code-loopback", + "verifier-loopback", + "http://localhost:1455/auth/callback", + { timeoutMs: 5000 }, + ); + + expect(result).toMatchObject({ + type: "success", + access: "access-token-loopback", + refresh: "refresh-token-loopback", + }); + expect( + (result as { type: "success"; access: string; refresh: string; expires: number }).expires, + ).toBeGreaterThan(0); + expect(release).toHaveBeenCalledOnce(); + } finally { + await closeServer(server); + } + }); + + it("rejects oversized token exchange responses from a real loopback HTTP server", async () => { + const oversizedPayload = "o".repeat(2 * 1024 * 1024); // 2 MiB > 1 MiB cap + const server = createServer((_req, res) => { + res.writeHead(200, { "content-type": "application/json" }); + res.end(oversizedPayload); + }); + const port = await listenLoopbackServer(server); + const release = vi.fn(async () => undefined); + + try { + ssrfMocks.fetchWithSsrFGuard.mockImplementation(async ({ init, signal }) => { + const response = await globalThis.fetch(`http://127.0.0.1:${port}`, { + ...init, + signal, + }); + return { response, release }; + }); + + const result = await testing.exchangeAuthorizationCode( + "code-loopback", + "verifier-loopback", + "http://localhost:1455/auth/callback", + { timeoutMs: 5000 }, + ); + + expect(result).toMatchObject({ type: "failed" }); + expect((result as { type: "failed"; message: string }).message).toContain("too large"); + expect(release).toHaveBeenCalledOnce(); + } finally { + await closeServer(server); + } + }); +}); diff --git a/extensions/openai/openai-chatgpt-oauth-flow.runtime.ts b/extensions/openai/openai-chatgpt-oauth-flow.runtime.ts index e5314f352460..a8542069d5d2 100644 --- a/extensions/openai/openai-chatgpt-oauth-flow.runtime.ts +++ b/extensions/openai/openai-chatgpt-oauth-flow.runtime.ts @@ -11,6 +11,7 @@ import { resolveOAuthTokenExpiresAt, resolveOAuthTokenLifetimeMs, } from "openclaw/plugin-sdk/provider-oauth-runtime"; +import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime"; import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; import { resolveCodexAuthIdentity } from "./openai-chatgpt-auth-identity.js"; import { @@ -39,6 +40,7 @@ const REDIRECT_URI = resolveRedirectUri(CALLBACK_HOST); const MANUAL_PROMPT_FALLBACK_MS = 15_000; const TOKEN_REQUEST_TIMEOUT_MS = 30_000; const SCOPE = "openid profile email offline_access"; +const OAUTH_TOKEN_RESPONSE_BODY_LIMIT_BYTES = 1 * 1024 * 1024; type TokenSuccess = { type: "success"; access: string; refresh: string; expires: number }; type TokenFailure = { type: "failed"; message: string; status?: number }; @@ -178,8 +180,17 @@ async function postTokenForm( auditContext: "openai-chatgpt-oauth-token", }); try { - const responseBody = await response.arrayBuffer(); - return new Response(responseBody, { + const responseBody = await readResponseWithLimit( + response, + OAUTH_TOKEN_RESPONSE_BODY_LIMIT_BYTES, + { + onOverflow: ({ size, maxBytes }) => + new Error( + `OpenAI Codex OAuth token response body too large: ${size} bytes (limit: ${maxBytes} bytes)`, + ), + }, + ); + return new Response(new Uint8Array(responseBody), { status: response.status, statusText: response.statusText, headers: response.headers,