mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
refactor(googlechat): guard API fetches and raw-fetch lint
This commit is contained in:
@@ -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<string, string> =>
|
||||
headers instanceof Headers
|
||||
@@ -19,19 +21,49 @@ async function fetchJson<T>(
|
||||
init: RequestInit,
|
||||
): Promise<T> {
|
||||
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<void> }> {
|
||||
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<void> {
|
||||
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: {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user