From cfb56d5baab7d9659117d34cf478191f74cddbdf Mon Sep 17 00:00:00 2001 From: mushuiyu886 Date: Sat, 27 Jun 2026 22:30:32 +0800 Subject: [PATCH] fix(clickclack): bound REST success JSON response reads (#96970) * fix(clickclack): bound REST success JSON response reads * test(clickclack): harden response cap proof --------- Co-authored-by: Vincent Koc (cherry picked from commit f4fa10c2c5acae26156868fe50f01d52a464f4b1) --- extensions/clickclack/src/http-client.test.ts | 94 +++++++++++++++++++ extensions/clickclack/src/http-client.ts | 7 +- 2 files changed, 99 insertions(+), 2 deletions(-) diff --git a/extensions/clickclack/src/http-client.test.ts b/extensions/clickclack/src/http-client.test.ts index a8b82872df1a..eaa335cea09f 100644 --- a/extensions/clickclack/src/http-client.test.ts +++ b/extensions/clickclack/src/http-client.test.ts @@ -1,6 +1,81 @@ +import { createServer, type Server } from "node:http"; import { describe, expect, it, vi } from "vitest"; import { createClickClackClient } from "./http-client.js"; +const LOOPBACK_RESPONSE_BYTES = 18 * 1024 * 1024; + +async function listenLoopbackServer(server: Server): Promise { + return await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + server.off("error", reject); + const address = server.address(); + if (!address || typeof address === "string") { + reject(new Error("expected loopback TCP address")); + return; + } + resolve(address.port); + }); + }); +} + +function createOversizedJsonServer(): { server: Server; closed: Promise } { + let resolveClosed: (sentBytes: number) => void = () => {}; + const closed = new Promise((resolve) => { + resolveClosed = resolve; + }); + const server = createServer((req, res) => { + let sentBytes = 0; + let stopped = false; + let prefixSent = false; + const prefixChunk = Buffer.from('{"user":{"id":"'); + const bodyChunk = Buffer.alloc(64 * 1024, 0x61); + const suffixChunk = Buffer.from('"}}'); + const writeBuffer = (buffer: Buffer) => { + sentBytes += buffer.length; + if (!res.write(buffer)) { + res.once("drain", writeChunks); + return false; + } + return true; + }; + const writeChunks = () => { + if (!prefixSent) { + prefixSent = true; + if (!writeBuffer(prefixChunk)) { + return; + } + } + while (true) { + if (stopped) { + return; + } + if (sentBytes + bodyChunk.length + suffixChunk.length >= LOOPBACK_RESPONSE_BYTES) { + break; + } + if (!writeBuffer(bodyChunk)) { + return; + } + } + if (!stopped) { + sentBytes += suffixChunk.length; + res.end(suffixChunk); + } + }; + res.writeHead(200, { connection: "close", "content-type": "application/json" }); + res.on("close", () => { + stopped = true; + resolveClosed(sentBytes); + }); + req.on("aborted", () => { + stopped = true; + res.destroy(); + }); + writeChunks(); + }); + return { server, closed }; +} + function streamedErrorResponse(body: string, limit: number) { const encoded = new TextEncoder().encode(body); let readCount = 0; @@ -39,6 +114,25 @@ function streamedErrorResponse(body: string, limit: number) { } describe("ClickClack HTTP client", () => { + it("bounds oversized success JSON responses and closes the stream early", async () => { + const { server, closed } = createOversizedJsonServer(); + const port = await listenLoopbackServer(server); + const client = createClickClackClient({ + baseUrl: `http://127.0.0.1:${port}`, + token: "test-token", + }); + + try { + await expect(client.me()).rejects.toThrow( + "ClickClack response: JSON response exceeds 16777216 bytes", + ); + const sentBytes = await closed; + expect(sentBytes).toBeLessThan(LOOPBACK_RESPONSE_BYTES); + } finally { + server.close(); + } + }); + it("bounds error response bodies without using raw response.text()", async () => { const streamed = streamedErrorResponse("x".repeat(9000), 8 * 1024); const fetchMock = vi.fn(async () => streamed.response); diff --git a/extensions/clickclack/src/http-client.ts b/extensions/clickclack/src/http-client.ts index 5ee275c481e8..d46439a61f4b 100644 --- a/extensions/clickclack/src/http-client.ts +++ b/extensions/clickclack/src/http-client.ts @@ -2,7 +2,10 @@ * Thin ClickClack REST/websocket client used by gateway, resolver, and outbound * delivery code. */ -import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http"; +import { + readProviderJsonResponse, + readResponseTextLimited, +} from "openclaw/plugin-sdk/provider-http"; import { WebSocket } from "ws"; import type { ClickClackChannel, @@ -44,7 +47,7 @@ export function createClickClackClient(options: ClientOptions) { const detail = await readResponseTextLimited(response, CLICKCLACK_ERROR_BODY_LIMIT_BYTES); throw new Error(`ClickClack ${response.status}: ${detail}`); } - return (await response.json()) as T; + return await readProviderJsonResponse(response, "ClickClack response"); } return {