diff --git a/extensions/googlechat/src/api.ts b/extensions/googlechat/src/api.ts index c71c4491650..0a9e8573685 100644 --- a/extensions/googlechat/src/api.ts +++ b/extensions/googlechat/src/api.ts @@ -1,10 +1,12 @@ import crypto from "node:crypto"; +import { fetchWithSsrFGuard } from "openclaw/plugin-sdk"; import type { ResolvedGoogleChatAccount } from "./accounts.js"; import { getGoogleChatAccessToken } from "./auth.js"; import type { GoogleChatReaction } from "./types.js"; const CHAT_API_BASE = "https://chat.googleapis.com/v1"; const CHAT_UPLOAD_BASE = "https://chat.googleapis.com/upload/v1"; +const GOOGLE_CHAT_ALLOWED_HOSTNAMES = ["chat.googleapis.com"]; const headersToObject = (headers?: HeadersInit): Record => headers instanceof Headers @@ -19,19 +21,49 @@ async function fetchJson( init: RequestInit, ): Promise { const token = await getGoogleChatAccessToken(account); - const res = await fetch(url, { - ...init, - headers: { - ...headersToObject(init.headers), - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", + const { response, release } = await fetchWithSsrFGuard({ + url, + init: { + ...init, + headers: { + ...headersToObject(init.headers), + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, }, + policy: { allowedHostnames: GOOGLE_CHAT_ALLOWED_HOSTNAMES }, + auditContext: "googlechat.api.json", }); - if (!res.ok) { - const text = await res.text().catch(() => ""); - throw new Error(`Google Chat API ${res.status}: ${text || res.statusText}`); + try { + if (!response.ok) { + const text = await response.text().catch(() => ""); + throw new Error(`Google Chat API ${response.status}: ${text || response.statusText}`); + } + return (await response.json()) as T; + } finally { + await release(); } - return (await res.json()) as T; +} + +async function fetchAuthorized( + account: ResolvedGoogleChatAccount, + url: string, + init: RequestInit, + auditContext: string, +): Promise<{ response: Response; release: () => Promise }> { + const token = await getGoogleChatAccessToken(account); + return await fetchWithSsrFGuard({ + url, + init: { + ...init, + headers: { + ...headersToObject(init.headers), + Authorization: `Bearer ${token}`, + }, + }, + policy: { allowedHostnames: GOOGLE_CHAT_ALLOWED_HOSTNAMES }, + auditContext, + }); } async function fetchOk( @@ -39,17 +71,14 @@ async function fetchOk( url: string, init: RequestInit, ): Promise { - const token = await getGoogleChatAccessToken(account); - const res = await fetch(url, { - ...init, - headers: { - ...headersToObject(init.headers), - Authorization: `Bearer ${token}`, - }, - }); - if (!res.ok) { - const text = await res.text().catch(() => ""); - throw new Error(`Google Chat API ${res.status}: ${text || res.statusText}`); + const { response, release } = await fetchAuthorized(account, url, init, "googlechat.api.ok"); + try { + if (!response.ok) { + const text = await response.text().catch(() => ""); + throw new Error(`Google Chat API ${response.status}: ${text || response.statusText}`); + } + } finally { + await release(); } } @@ -59,52 +88,54 @@ async function fetchBuffer( init?: RequestInit, options?: { maxBytes?: number }, ): Promise<{ buffer: Buffer; contentType?: string }> { - const token = await getGoogleChatAccessToken(account); - const res = await fetch(url, { - ...init, - headers: { - ...headersToObject(init?.headers), - Authorization: `Bearer ${token}`, - }, - }); - if (!res.ok) { - const text = await res.text().catch(() => ""); - throw new Error(`Google Chat API ${res.status}: ${text || res.statusText}`); - } - const maxBytes = options?.maxBytes; - const lengthHeader = res.headers.get("content-length"); - if (maxBytes && lengthHeader) { - const length = Number(lengthHeader); - if (Number.isFinite(length) && length > maxBytes) { - throw new Error(`Google Chat media exceeds max bytes (${maxBytes})`); + const { response, release } = await fetchAuthorized( + account, + url, + init ?? {}, + "googlechat.api.buffer", + ); + try { + if (!response.ok) { + const text = await response.text().catch(() => ""); + throw new Error(`Google Chat API ${response.status}: ${text || response.statusText}`); } - } - if (!maxBytes || !res.body) { - const buffer = Buffer.from(await res.arrayBuffer()); - const contentType = res.headers.get("content-type") ?? undefined; + const maxBytes = options?.maxBytes; + const lengthHeader = response.headers.get("content-length"); + if (maxBytes && lengthHeader) { + const length = Number(lengthHeader); + if (Number.isFinite(length) && length > maxBytes) { + throw new Error(`Google Chat media exceeds max bytes (${maxBytes})`); + } + } + if (!maxBytes || !response.body) { + const buffer = Buffer.from(await response.arrayBuffer()); + const contentType = response.headers.get("content-type") ?? undefined; + return { buffer, contentType }; + } + const reader = response.body.getReader(); + const chunks: Buffer[] = []; + let total = 0; + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + if (!value) { + continue; + } + total += value.length; + if (total > maxBytes) { + await reader.cancel(); + throw new Error(`Google Chat media exceeds max bytes (${maxBytes})`); + } + chunks.push(Buffer.from(value)); + } + const buffer = Buffer.concat(chunks, total); + const contentType = response.headers.get("content-type") ?? undefined; return { buffer, contentType }; + } finally { + await release(); } - const reader = res.body.getReader(); - const chunks: Buffer[] = []; - let total = 0; - while (true) { - const { done, value } = await reader.read(); - if (done) { - break; - } - if (!value) { - continue; - } - total += value.length; - if (total > maxBytes) { - await reader.cancel(); - throw new Error(`Google Chat media exceeds max bytes (${maxBytes})`); - } - chunks.push(Buffer.from(value)); - } - const buffer = Buffer.concat(chunks, total); - const contentType = res.headers.get("content-type") ?? undefined; - return { buffer, contentType }; } export async function sendGoogleChatMessage(params: { @@ -183,26 +214,33 @@ export async function uploadGoogleChatAttachment(params: { Buffer.from(footer, "utf8"), ]); - const token = await getGoogleChatAccessToken(account); const url = `${CHAT_UPLOAD_BASE}/${space}/attachments:upload?uploadType=multipart`; - const res = await fetch(url, { - method: "POST", - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": `multipart/related; boundary=${boundary}`, + const { response, release } = await fetchAuthorized( + account, + url, + { + method: "POST", + headers: { + "Content-Type": `multipart/related; boundary=${boundary}`, + }, + body, }, - body, - }); - if (!res.ok) { - const text = await res.text().catch(() => ""); - throw new Error(`Google Chat upload ${res.status}: ${text || res.statusText}`); + "googlechat.api.upload", + ); + try { + if (!response.ok) { + const text = await response.text().catch(() => ""); + throw new Error(`Google Chat upload ${response.status}: ${text || response.statusText}`); + } + const payload = (await response.json()) as { + attachmentDataRef?: { attachmentUploadToken?: string }; + }; + return { + attachmentUploadToken: payload.attachmentDataRef?.attachmentUploadToken, + }; + } finally { + await release(); } - const payload = (await res.json()) as { - attachmentDataRef?: { attachmentUploadToken?: string }; - }; - return { - attachmentUploadToken: payload.attachmentDataRef?.attachmentUploadToken, - }; } export async function downloadGoogleChatMedia(params: { diff --git a/scripts/check-no-raw-channel-fetch.mjs b/scripts/check-no-raw-channel-fetch.mjs index 814d3777918..4ef5d658add 100644 --- a/scripts/check-no-raw-channel-fetch.mjs +++ b/scripts/check-no-raw-channel-fetch.mjs @@ -21,6 +21,9 @@ const sourceRoots = [ // Temporary allowlist for legacy callsites. New raw fetch callsites in channel/plugin runtime // code should be rejected and migrated to fetchWithSsrFGuard/shared channel helpers. +// Supports: +// - exact callsite: "path/to/file.ts:42" +// - file scope: "path/to/file.ts" or "path/to/file.ts:*" const allowedRawFetchCallsites = new Set([ "extensions/bluebubbles/src/types.ts:131", "extensions/feishu/src/streaming-card.ts:31", @@ -32,10 +35,6 @@ const allowedRawFetchCallsites = new Set([ "extensions/google-gemini-cli-auth/oauth.ts:447", "extensions/google-gemini-cli-auth/oauth.ts:507", "extensions/google-gemini-cli-auth/oauth.ts:575", - "extensions/googlechat/src/api.ts:22", - "extensions/googlechat/src/api.ts:43", - "extensions/googlechat/src/api.ts:63", - "extensions/googlechat/src/api.ts:184", "extensions/googlechat/src/auth.ts:82", "extensions/matrix/src/directory-live.ts:41", "extensions/matrix/src/matrix/client/config.ts:171", @@ -65,6 +64,20 @@ const allowedRawFetchCallsites = new Set([ "src/slack/monitor/media.ts:108", ]); +function isAllowlistedRawFetchCallsite(callsite) { + if (allowedRawFetchCallsites.has(callsite)) { + return true; + } + const [filePath] = callsite.split(":"); + if (!filePath) { + return false; + } + if (allowedRawFetchCallsites.has(filePath)) { + return true; + } + return allowedRawFetchCallsites.has(`${filePath}:*`); +} + function isTestLikeFile(filePath) { return ( filePath.endsWith(".test.ts") || @@ -177,7 +190,7 @@ export async function main() { const relPath = path.relative(repoRoot, filePath).replaceAll(path.sep, "/"); for (const line of findRawFetchCallLines(content, filePath)) { const callsite = `${relPath}:${line}`; - if (allowedRawFetchCallsites.has(callsite)) { + if (isAllowlistedRawFetchCallsite(callsite)) { continue; } violations.push(callsite);