mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-07 19:48:31 +08:00
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. https://github.com/openclaw/openclaw/pull/99479 * test(openai): verify OAuth response release --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
@@ -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<number> {
|
||||
return await new Promise<number>((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<void> {
|
||||
await new Promise<void>((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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user