From 7998802f5ae7952ee8a0a28f5eca7e4984a7a5de Mon Sep 17 00:00:00 2001 From: Alix-007 Date: Mon, 29 Jun 2026 10:57:01 +0800 Subject: [PATCH] fix(google-meet): bound Drive document export reads to prevent OOM (#97620) * fix(google-meet): bound Drive document export reads to prevent OOM * test(google-meet): return full guarded fetch result --------- Co-authored-by: Vincent Koc (cherry picked from commit 2001b15f5b92d653464cbd847c28c136bdb465a7) --- extensions/google-meet/src/drive.test.ts | 77 ++++++++++++++++++++++++ extensions/google-meet/src/drive.ts | 3 +- 2 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 extensions/google-meet/src/drive.test.ts diff --git a/extensions/google-meet/src/drive.test.ts b/extensions/google-meet/src/drive.test.ts new file mode 100644 index 000000000000..70d9534f2293 --- /dev/null +++ b/extensions/google-meet/src/drive.test.ts @@ -0,0 +1,77 @@ +// Google Meet tests cover bounded Drive document export response reads. +import { describe, expect, it, vi } from "vitest"; +import { exportGoogleDriveDocumentText } from "./drive.js"; + +vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({ + fetchWithSsrFGuard: vi.fn(), +})); + +import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; + +const mockFetch = vi.mocked(fetchWithSsrFGuard); + +function makeStreamResponse(sizeBytes: number, status = 200): Response { + const chunk = new Uint8Array(Math.min(sizeBytes, 65536)).fill(0x78); // 'x' + let sent = 0; + const stream = new ReadableStream({ + pull(controller) { + if (sent >= sizeBytes) { + controller.close(); + return; + } + const remaining = sizeBytes - sent; + const toSend = Math.min(chunk.length, remaining); + controller.enqueue(chunk.subarray(0, toSend)); + sent += toSend; + }, + }); + return new Response(stream, { + status, + headers: { "Content-Type": "text/plain" }, + }); +} + +describe("exportGoogleDriveDocumentText bound", () => { + it("returns document text when response is within the 16 MiB cap", async () => { + const UNDER_CAP = 256; + const response = makeStreamResponse(UNDER_CAP); + mockFetch.mockResolvedValueOnce({ + response, + finalUrl: "https://www.googleapis.com/drive/v3/files/doc-id/export?mimeType=text%2Fplain", + release: vi.fn(async () => undefined), + }); + + const result = await exportGoogleDriveDocumentText({ + accessToken: "tok", + documentId: "doc-id", + }); + + expect(typeof result).toBe("string"); + expect(result.length).toBeGreaterThan(0); + }); + + it("rejects with a size error when response exceeds 16 MiB cap (fail-closed)", async () => { + const OVER_CAP = 17 * 1024 * 1024; // 17 MiB + const response = makeStreamResponse(OVER_CAP); + const release = vi.fn(async () => undefined); + mockFetch.mockResolvedValueOnce({ + response, + finalUrl: "https://www.googleapis.com/drive/v3/files/doc-id/export?mimeType=text%2Fplain", + release, + }); + + await expect( + exportGoogleDriveDocumentText({ accessToken: "tok", documentId: "doc-id" }), + ).rejects.toThrow(/exceeds/i); + + expect(release).toHaveBeenCalledTimes(1); + }); + + it("negative-control: bare response.text() buffers the full oversized body (no protection)", async () => { + const OVER_CAP = 17 * 1024 * 1024; // 17 MiB + const response = makeStreamResponse(OVER_CAP); + // Calling response.text() directly buffers everything without throwing. + const text = await response.text(); + expect(text.length).toBeGreaterThan(16 * 1024 * 1024); + }); +}); diff --git a/extensions/google-meet/src/drive.ts b/extensions/google-meet/src/drive.ts index a47da9188247..396f62c8517d 100644 --- a/extensions/google-meet/src/drive.ts +++ b/extensions/google-meet/src/drive.ts @@ -1,5 +1,6 @@ // Google Meet plugin module implements drive behavior. import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; +import { readProviderTextResponse } from "openclaw/plugin-sdk/provider-http"; import { googleApiError } from "./google-api-errors.js"; const GOOGLE_DRIVE_API_BASE_URL = "https://www.googleapis.com/drive/v3"; @@ -64,7 +65,7 @@ export async function exportGoogleDriveDocumentText(params: { scopes: [GOOGLE_DRIVE_MEET_SCOPE], }); } - return await response.text(); + return await readProviderTextResponse(response, "Google Drive files.export"); } finally { await release(); }