mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
fix(google): bound OAuth JSON response reads (#97587)
Co-authored-by: NIO <nocodet@mail.com>
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user