From 2e967ea61dd45d46d55ff75ece8e9d876f194f96 Mon Sep 17 00:00:00 2001 From: NIO Date: Tue, 7 Jul 2026 01:01:25 +0800 Subject: [PATCH] fix(google): bound OAuth JSON response reads (#97587) Co-authored-by: NIO --- extensions/google/oauth.project.ts | 18 ++-- extensions/google/oauth.test.ts | 134 +++++++++++++++++++++++++++++ extensions/google/oauth.token.ts | 9 +- 3 files changed, 151 insertions(+), 10 deletions(-) diff --git a/extensions/google/oauth.project.ts b/extensions/google/oauth.project.ts index 701c21454646..d8325973d6e4 100644 --- a/extensions/google/oauth.project.ts +++ b/extensions/google/oauth.project.ts @@ -1,4 +1,5 @@ // Google plugin module implements oauth.project behavior. +import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http"; import { fetchWithTimeout } from "./oauth.http.js"; import { CODE_ASSIST_ENDPOINT_PROD, @@ -21,7 +22,7 @@ async function getUserEmail(accessToken: string): Promise { headers: { Authorization: `Bearer ${accessToken}` }, }); if (response.ok) { - const data = (await response.json()) as { email?: string }; + const data = await readProviderJsonResponse<{ email?: string }>(response, "google.userinfo"); return data.email; } } catch { @@ -74,10 +75,10 @@ async function pollOperation( if (!response.ok) { continue; } - const data = (await response.json()) as { + const data = await readProviderJsonResponse<{ done?: boolean; response?: { cloudaicompanionProject?: { id?: string } }; - }; + }>(response, "google.poll-operation"); if (data.done) { return data; } @@ -135,7 +136,10 @@ async function discoverProject(accessToken: string): Promise { }); if (!response.ok) { - const errorPayload = await response.json().catch(() => null); + const errorPayload = await readProviderJsonResponse( + response, + "google.load-code-assist", + ).catch(() => null); if (isVpcScAffected(errorPayload)) { data = { currentTier: { id: TIER_STANDARD } }; activeEndpoint = endpoint; @@ -146,7 +150,7 @@ async function discoverProject(accessToken: string): Promise { continue; } - data = (await response.json()) as typeof data; + data = await readProviderJsonResponse(response, "google.load-code-assist"); activeEndpoint = endpoint; loadError = undefined; break; @@ -211,11 +215,11 @@ async function discoverProject(accessToken: string): Promise { throw new Error(`onboardUser failed: ${onboardResponse.status} ${onboardResponse.statusText}`); } - let lro = (await onboardResponse.json()) as { + let lro = await readProviderJsonResponse<{ done?: boolean; name?: string; response?: { cloudaicompanionProject?: { id?: string } }; - }; + }>(onboardResponse, "google.onboard-user"); if (!lro.done && lro.name) { lro = await pollOperation(activeEndpoint, lro.name, headers); diff --git a/extensions/google/oauth.test.ts b/extensions/google/oauth.test.ts index facba2a12503..7242bcc2e1c9 100644 --- a/extensions/google/oauth.test.ts +++ b/extensions/google/oauth.test.ts @@ -649,6 +649,7 @@ describe("loginGeminiCliOAuth", () => { platform: "PLATFORM_UNSPECIFIED", pluginType: "GEMINI", } as const; + const OVERSIZED_OAUTH_RESPONSE_BYTES = 17 * 1024 * 1024; function getRequestUrl(input: string | URL | Request): string { if (typeof input === "string") { @@ -680,6 +681,40 @@ describe("loginGeminiCliOAuth", () => { }); } + function oversizedJsonStringFieldResponse(params: { + prefix: string; + suffix: string; + targetBytes?: number; + }): Response { + const encoder = new TextEncoder(); + const prefix = encoder.encode(params.prefix); + const suffix = encoder.encode(params.suffix); + const chunk = new Uint8Array(64 * 1024).fill(0x61); + const targetBytes = params.targetBytes ?? OVERSIZED_OAUTH_RESPONSE_BYTES; + let sentBytes = 0; + return new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(prefix); + sentBytes += prefix.byteLength; + }, + pull(controller) { + if (sentBytes >= targetBytes) { + controller.enqueue(suffix); + controller.close(); + return; + } + controller.enqueue(chunk); + sentBytes += chunk.byteLength; + }, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ); + } + function responseTextBodyWithTextTrap(body: string, status = 500) { const response = new Response(body, { status, @@ -837,6 +872,7 @@ describe("loginGeminiCliOAuth", () => { } } setOAuthSettingsFsForTest(); + vi.restoreAllMocks(); vi.unstubAllGlobals(); }); @@ -1117,4 +1153,102 @@ describe("loginGeminiCliOAuth", () => { expect(Number.isSafeInteger(result.expires)).toBe(true); expect(result.expires).toBeLessThanOrEqual(beforeRefresh); }); + + it("rejects an oversized token exchange response body", async () => { + // End-to-end OAuth path: oversized upstream bodies fail closed before auth + // completes. After #97628 the shared fetchWithTimeout cap fires first; + // readProviderJsonResponse remains the labeled parse boundary afterward. + installGeminiOAuthFetchMock(() => undefined, { + tokenResponse: () => + oversizedJsonStringFieldResponse({ + prefix: '{"access_token":"', + suffix: '","refresh_token":"r","expires_in":3600}', + }), + }); + + const { exchangeCodeForTokens } = await import("./oauth.token.js"); + await expect(exchangeCodeForTokens("oauth-code", "pkce-verifier")).rejects.toThrow( + /google HTTP fetch: body exceeds|google\.token.*exceeds|Content too large/, + ); + }); + + it("rejects an oversized token body at the JSON parse boundary", async () => { + // Defense-in-depth: if fetchWithTimeout already returned a buffered Response, + // readProviderJsonResponse still caps JSON.parse on the OAuth token path. + vi.resetModules(); + const oauthHttp = await import("./oauth.http.js"); + const originalFetchWithTimeout = oauthHttp.fetchWithTimeout; + vi.spyOn(oauthHttp, "fetchWithTimeout").mockImplementation(async (url, init, timeoutMs) => { + if (url === TOKEN_URL) { + return oversizedJsonStringFieldResponse({ + prefix: '{"access_token":"', + suffix: '","refresh_token":"r","expires_in":3600}', + }); + } + return originalFetchWithTimeout(url, init, timeoutMs); + }); + installGeminiOAuthFetchMock(() => undefined); + + const { exchangeCodeForTokens } = await import("./oauth.token.js"); + await expect(exchangeCodeForTokens("oauth-code", "pkce-verifier")).rejects.toThrow( + /google\.token.*exceeds|Content too large/, + ); + }); + + it("rejects an oversized loadCodeAssist success response body", async () => { + // discoverProject loops over all 3 LOAD endpoints; each must return the + // oversized body so that bound errors propagate for the whole loop. + const oversizedResponse = () => + oversizedJsonStringFieldResponse({ + prefix: '{"currentTier":{"id":"standard-tier"},"cloudaicompanionProject":{"id":"', + suffix: '"}}', + }); + installGeminiOAuthFetchMock(({ url }) => { + if (url === LOAD_PROD || url === LOAD_DAILY || url === LOAD_AUTOPUSH) { + return oversizedResponse(); + } + return undefined; + }); + + const { resolveGoogleOAuthIdentity } = await import("./oauth.project.js"); + await expect(resolveGoogleOAuthIdentity("access-token")).rejects.toThrow( + /google HTTP fetch: body exceeds|google\.load-code-assist.*exceeds|Content too large/, + ); + }); + + it("swallows bound error on oversized userinfo body and returns undefined email", async () => { + // getUserEmail catches all errors; an oversized userinfo body should not + // propagate but email must be undefined. After #97628 the fetch cap may + // truncate the upstream body before parse, so the swallowed error can be + // either a labeled size cap or malformed JSON — either proves the bound fired. + vi.stubGlobal( + "fetch", + vi.fn(async (input: string | URL | Request, _init?: RequestInit) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url === USERINFO_URL) { + return oversizedJsonStringFieldResponse({ + prefix: '{"email":"', + suffix: '"}', + }); + } + if (url === LOAD_PROD) { + return new Response( + JSON.stringify({ + currentTier: { id: "standard-tier" }, + cloudaicompanionProject: { id: "proj-bound-test" }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + return new Response(JSON.stringify({ error: "not found" }), { status: 503 }); + }), + ); + + const { resolveGoogleOAuthIdentity } = await import("./oauth.project.js"); + const result = await resolveGoogleOAuthIdentity("access-token"); + expect(result.projectId).toBe("proj-bound-test"); + // email is undefined: the bound error was thrown and swallowed by getUserEmail + expect(result.email).toBeUndefined(); + }); }); diff --git a/extensions/google/oauth.token.ts b/extensions/google/oauth.token.ts index 8ace40f1afc6..5d1df9ac3594 100644 --- a/extensions/google/oauth.token.ts +++ b/extensions/google/oauth.token.ts @@ -3,7 +3,10 @@ import { asDateTimestampMs, resolveExpiresAtMsFromDurationSeconds, } from "openclaw/plugin-sdk/number-runtime"; -import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http"; +import { + readProviderJsonResponse, + readResponseTextLimited, +} from "openclaw/plugin-sdk/provider-http"; import { resolveOAuthClientConfig } from "./oauth.credentials.js"; import { fetchWithTimeout } from "./oauth.http.js"; import { resolveGoogleOAuthIdentity, resolveGooglePersonalOAuthIdentity } from "./oauth.project.js"; @@ -36,11 +39,11 @@ async function requestTokenGrant(body: URLSearchParams): Promise<{ throw new Error(`Token exchange failed: ${errorText}`); } - return (await response.json()) as { + return readProviderJsonResponse<{ access_token?: string; refresh_token?: string; expires_in?: unknown; - }; + }>(response, "google.token"); } function resolveExpiredTokenTimestampMs(nowMs: number): number {