From a6aaba76ac66ba3edacafd337672ab8ae1660b70 Mon Sep 17 00:00:00 2001 From: wangmiao0668000666 Date: Mon, 29 Jun 2026 12:49:13 +0800 Subject: [PATCH] fix(google): bound OAuth response body reads --- extensions/google/oauth.http.test.ts | 172 +++++++++++++++++++++++++++ extensions/google/oauth.http.ts | 24 +++- 2 files changed, 194 insertions(+), 2 deletions(-) create mode 100644 extensions/google/oauth.http.test.ts diff --git a/extensions/google/oauth.http.test.ts b/extensions/google/oauth.http.test.ts new file mode 100644 index 00000000000..f08a392858a --- /dev/null +++ b/extensions/google/oauth.http.test.ts @@ -0,0 +1,172 @@ +// Google tests cover oauth.http body-byte-cap for the Gemini CLI OAuth +// token-exchange/identity calls. +import http from "node:http"; +import type { AddressInfo } from "node:net"; +import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { TOKEN_URL } from "./oauth.shared.js"; + +const fetchWithSsrFGuardMock = vi.fn(); +const releaseMock = vi.fn(async () => undefined); + +vi.mock("openclaw/plugin-sdk/ssrf-runtime", async () => { + const actual = await vi.importActual( + "openclaw/plugin-sdk/ssrf-runtime", + ); + return { + ...actual, + fetchWithSsrFGuard: (params: unknown) => fetchWithSsrFGuardMock(params), + }; +}); + +const { fetchWithTimeout } = await import("./oauth.http.js"); + +describe("oauth.http fetchWithTimeout body byte cap", () => { + beforeEach(() => { + fetchWithSsrFGuardMock.mockReset(); + releaseMock.mockClear(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("caps oversized response body at 16 MiB with labeled overflow error", async () => { + // Build a Response with a body that exceeds the 16 MiB cap. + // 1 MiB chunks × 18 chunks = 18 MiB queued; the bounded reader reads + // up to the 16 MiB cap (16 chunks = 16777216 bytes) and one extra + // chunk before throwing on overflow, so the labeled `size` is the + // cap plus the trailing chunk: 16777216 + 1048576 = 17825792 bytes. + const CHUNK = 1024 * 1024; + let sent = 0; + const body = new ReadableStream({ + pull(controller) { + if (sent < 18) { + controller.enqueue(new Uint8Array(CHUNK)); + sent++; + } else { + controller.close(); + } + }, + }); + fetchWithSsrFGuardMock.mockResolvedValue({ + response: new Response(body, { + status: 200, + headers: { "content-type": "application/json" }, + }), + finalUrl: TOKEN_URL, + release: releaseMock, + }); + + await expect(fetchWithTimeout(TOKEN_URL, { method: "POST" })).rejects.toThrow( + /google HTTP fetch: body exceeds 16777216 bytes \(got 17825792\)/, + ); + expect(releaseMock).toHaveBeenCalledOnce(); + }); + + it("returns a Response for normal-size bodies", async () => { + fetchWithSsrFGuardMock.mockResolvedValue({ + response: new Response('{"access_token":"abc","expires_in":3600}', { + status: 200, + headers: { "content-type": "application/json" }, + }), + finalUrl: TOKEN_URL, + release: releaseMock, + }); + + const res = await fetchWithTimeout(TOKEN_URL, { method: "POST" }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ access_token: "abc", expires_in: 3600 }); + expect(releaseMock).toHaveBeenCalledOnce(); + }); +}); + +// Real-wire loopback proof. These tests bypass `fetchWithSsrFGuard` (which +// blocks 127.0.0.1 by design) and exercise `readResponseWithLimit` directly +// against a real `http.createServer` listener — the same helper that +// `fetchWithTimeout` calls inside its try/finally block. Captured vitest +// output for these two tests is the ClawSweeper "real behavior proof" required +// before merge. +describe("oauth.http bounded-read real wire proof (loopback http.createServer)", () => { + it("caps an oversized body streamed chunked over real wire", async () => { + const CHUNK = 1024 * 1024; + const MAX = 16 * 1024 * 1024; + const TOTAL = 18 * 1024 * 1024; + const server = http.createServer((req, res) => { + res.writeHead(200, { "content-type": "application/octet-stream" }); + let sent = 0; + const tick = setInterval(() => { + if (sent < 18) { + res.write(Buffer.alloc(CHUNK)); + sent++; + } else { + clearInterval(tick); + res.end(); + } + }, 1); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => resolve()); + }); + const port = (server.address() as AddressInfo).port; + + let captured: Error | undefined; + try { + const response = await fetch(`http://127.0.0.1:${port}/`); + // Wire framing merges TCP packets, so the exact reported size varies by + // runtime. The stable invariant is that the cap fires after MAX. + try { + await readResponseWithLimit(response, MAX, { + onOverflow: ({ size, maxBytes }) => + new Error(`real wire: body exceeds ${maxBytes} bytes (got ${size})`), + }); + } catch (err) { + captured = err as Error; + } + expect(captured).toBeInstanceOf(Error); + const match = captured!.message.match(/real wire: body exceeds \d+ bytes \(got (\d+)\)/); + expect(match).not.toBeNull(); + const got = Number(match![1]); + expect(got).toBeGreaterThan(MAX); + // Print to vitest stdout for PR-body real behavior proof capture. + console.log( + `[oauth.http loopback proof] oversized path: cap=${MAX} reported=${got} server_total=${TOTAL}`, + ); + } finally { + await new Promise((resolve) => { + server.close(() => resolve()); + }); + } + }); + + it("returns a Buffer for normal-size responses on real wire", async () => { + const bodyText = '{"access_token":"loopback","expires_in":3600}'; + const server = http.createServer((req, res) => { + res.writeHead(200, { "content-type": "application/json" }); + res.end(bodyText); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => resolve()); + }); + const port = (server.address() as AddressInfo).port; + + try { + const response = await fetch(`http://127.0.0.1:${port}/`); + const body = await readResponseWithLimit(response, 16 * 1024 * 1024, { + onOverflow: ({ size, maxBytes }) => + new Error(`real wire: body exceeds ${maxBytes} bytes (got ${size})`), + }); + expect(body.byteLength).toBe(Buffer.byteLength(bodyText, "utf8")); + expect(new TextDecoder("utf-8").decode(body)).toBe(bodyText); + console.log( + `[oauth.http loopback proof] normal path: cap=16777216 returned=${body.byteLength} body=${JSON.stringify(new TextDecoder("utf-8").decode(body))}`, + ); + } finally { + await new Promise((resolve) => { + server.close(() => resolve()); + }); + } + }); +}); diff --git a/extensions/google/oauth.http.ts b/extensions/google/oauth.http.ts index 1ef8dd9700e..31f3432a9ec 100644 --- a/extensions/google/oauth.http.ts +++ b/extensions/google/oauth.http.ts @@ -3,9 +3,12 @@ import { shouldUseEnvHttpProxyForUrl, withTrustedEnvProxyGuardedFetchMode, } from "openclaw/plugin-sdk/fetch-runtime"; +import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime"; import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; import { DEFAULT_FETCH_TIMEOUT_MS } from "./oauth.shared.js"; +const GOOGLE_OAUTH_BODY_MAX_BYTES = 16 * 1024 * 1024; + export async function fetchWithTimeout( url: string, init: RequestInit, @@ -18,8 +21,25 @@ export async function fetchWithTimeout( : guardedOptions, ); try { - const body = await response.arrayBuffer(); - return new Response(body, { + // 16 MiB cap. A hostile or broken Google OAuth endpoint (or any + // accounts.google.com mirror / enterprise proxy) cannot force the + // runtime to buffer an unbounded body before the caller sees it. + // Complements #97587, which caps at the call site — this is the + // shared entry-point cap. + const body = await readResponseWithLimit(response, GOOGLE_OAUTH_BODY_MAX_BYTES, { + onOverflow: ({ size, maxBytes }) => + new Error(`google HTTP fetch: body exceeds ${maxBytes} bytes (got ${size})`), + }); + // `readResponseWithLimit` returns a `Buffer` (Node Uint8Array view). The + // global `Response` constructor accepts `BufferSource` (Uint8Array / + // ArrayBuffer) as a body; cast through `BodyInit` because `Buffer.buffer` + // is typed as `ArrayBufferLike` (could be `ArrayBuffer` or + // `SharedArrayBuffer`), but the helper always returns a regular `Buffer` + // backed by an `ArrayBuffer` with no shared-memory paths. The same + // wrap-shape is used by the googlechat google-auth helper at + // extensions/googlechat/src/google-auth.runtime.ts:454. + const bodyBytes = new Uint8Array(body.buffer, body.byteOffset, body.byteLength); + return new Response(bodyBytes as unknown as BodyInit, { status: response.status, statusText: response.statusText, headers: response.headers,