fix(google): bound OAuth JSON response reads (#97587)

Co-authored-by: NIO <nocodet@mail.com>
This commit is contained in:
NIO
2026-07-07 01:01:25 +08:00
committed by GitHub
parent c2cdffa41b
commit 2e967ea61d
3 changed files with 151 additions and 10 deletions
+11 -7
View File
@@ -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<string | undefined> {
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<string> {
});
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<string> {
continue;
}
data = (await response.json()) as typeof data;
data = await readProviderJsonResponse<typeof data>(response, "google.load-code-assist");
activeEndpoint = endpoint;
loadError = undefined;
break;
@@ -211,11 +215,11 @@ async function discoverProject(accessToken: string): Promise<string> {
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);
+134
View File
@@ -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<Uint8Array>({
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();
});
});
+6 -3
View File
@@ -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 {