From 9dfbf6a8a06c2018cf45931eaaf2e2b877449dc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yi=C4=9Fit=20ERDO=C4=9EAN?= Date: Sat, 1 Aug 2026 06:54:06 +0300 Subject: [PATCH] fix(googlechat): cancel unread auth response bodies before release (#115873) The size guard in readGoogleAuthResponseBytes inspects content-length and throws before it reaches response.body.getReader(), so an oversized auth response leaves the stream untouched and the finally block releases the dispatcher without cancelling it. This is the same shape #111290 just fixed in api.ts, applied to the auth transport's own guard site. --- .../src/google-auth.fetchok.transport.test.ts | 86 +++++++++++++++++++ .../googlechat/src/google-auth.runtime.ts | 6 ++ 2 files changed, 92 insertions(+) create mode 100644 extensions/googlechat/src/google-auth.fetchok.transport.test.ts diff --git a/extensions/googlechat/src/google-auth.fetchok.transport.test.ts b/extensions/googlechat/src/google-auth.fetchok.transport.test.ts new file mode 100644 index 000000000000..5a5b72c5eb2a --- /dev/null +++ b/extensions/googlechat/src/google-auth.fetchok.transport.test.ts @@ -0,0 +1,86 @@ +// Exercise Google auth requests through a real guarded HTTP transport. +import { createServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +const loopback = vi.hoisted(() => ({ + baseUrl: "", + contentLength: "", + releases: [] as Array<{ bodyIsNull: boolean; bodyUsed: boolean }>, +})); + +vi.mock("openclaw/plugin-sdk/ssrf-runtime", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetchWithSsrFGuard: async (...args: Parameters) => { + const [params] = args; + const guarded = await actual.fetchWithSsrFGuard({ + ...params, + policy: { allowPrivateNetwork: true }, + url: loopback.baseUrl, + }); + return { + ...guarded, + release: async () => { + loopback.releases.push({ + bodyIsNull: guarded.response.body === null, + bodyUsed: guarded.response.bodyUsed, + }); + await guarded.release(); + }, + }; + }, + }; +}); + +const { getGoogleAuthTransport } = await import("./google-auth.runtime.js"); + +const RESPONSE_BODY = '{"access_token":"probe"}'; + +let server: Server; + +beforeAll(async () => { + server = createServer((_req, res) => { + res.writeHead(200, { + "content-length": loopback.contentLength || String(RESPONSE_BODY.length), + "content-type": "application/json", + }); + res.end(RESPONSE_BODY); + }); + await new Promise((resolve) => { + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address() as AddressInfo; + loopback.baseUrl = `http://127.0.0.1:${address.port}/token`; +}); + +afterAll(async () => { + await new Promise((resolve) => { + server.close(() => resolve()); + }); +}); + +beforeEach(() => { + loopback.contentLength = ""; + loopback.releases = []; +}); + +describe("google auth guarded fetch", () => { + it("cancels the unread body when the size guard rejects the response", async () => { + loopback.contentLength = String(1024 * 1024 + 1); + const transport = await getGoogleAuthTransport(); + + await expect(transport.request({ url: loopback.baseUrl })).rejects.toThrow(); + + expect(loopback.releases).toEqual([{ bodyIsNull: false, bodyUsed: true }]); + }); + + it("leaves a fully read response alone", async () => { + const transport = await getGoogleAuthTransport(); + + await transport.request({ url: loopback.baseUrl }); + + expect(loopback.releases).toEqual([{ bodyIsNull: false, bodyUsed: true }]); + }); +}); diff --git a/extensions/googlechat/src/google-auth.runtime.ts b/extensions/googlechat/src/google-auth.runtime.ts index aa91209216fb..fdceb33aac53 100644 --- a/extensions/googlechat/src/google-auth.runtime.ts +++ b/extensions/googlechat/src/google-auth.runtime.ts @@ -432,6 +432,12 @@ function createGoogleAuthFetch(): FetchLike { statusText: response.statusText, }); } finally { + // The size guard can reject before the stream is touched, leaving an + // unread body. Start cancellation before release; awaiting it can + // deadlock when debug capture tees the stream. + if (!response.bodyUsed) { + void response.body?.cancel().catch(() => undefined); + } await release(); } };