From 18d2bc441cc06d9e953eed033cbfa2cc28e3d83b Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 29 May 2026 20:12:23 +0200 Subject: [PATCH] fix(e2e): harden kitchen sink probe body caps --- scripts/e2e/kitchen-sink-rpc-walk.mjs | 25 +++++++++-- test/scripts/kitchen-sink-rpc-walk.test.ts | 50 ++++++++++++++++++++++ 2 files changed, 71 insertions(+), 4 deletions(-) diff --git a/scripts/e2e/kitchen-sink-rpc-walk.mjs b/scripts/e2e/kitchen-sink-rpc-walk.mjs index bce4d9d611d..57f474333f0 100644 --- a/scripts/e2e/kitchen-sink-rpc-walk.mjs +++ b/scripts/e2e/kitchen-sink-rpc-walk.mjs @@ -512,9 +512,22 @@ export async function fetchJson(url, options = {}) { } export async function readBoundedResponseText(response, byteLimit = FETCH_BODY_MAX_BYTES) { + const contentLength = response.headers?.get?.("content-length"); + if (contentLength) { + const parsedContentLength = Number(contentLength); + if (Number.isFinite(parsedContentLength) && parsedContentLength > byteLimit) { + await response.body?.cancel?.().catch(() => undefined); + throw createFetchBodyTooLargeError(byteLimit); + } + } + const reader = response.body?.getReader?.(); if (!reader) { - return await response.text(); + const text = await response.text(); + if (Buffer.byteLength(text, "utf8") > byteLimit) { + throw createFetchBodyTooLargeError(byteLimit); + } + return text; } const chunks = []; let totalBytes = 0; @@ -527,15 +540,19 @@ export async function readBoundedResponseText(response, byteLimit = FETCH_BODY_M totalBytes += chunk.byteLength; if (totalBytes > byteLimit) { await reader.cancel().catch(() => undefined); - throw Object.assign(new Error(`fetch response body exceeded ${byteLimit} bytes`), { - code: "ETOOBIG", - }); + throw createFetchBodyTooLargeError(byteLimit); } chunks.push(chunk); } return Buffer.concat(chunks, totalBytes).toString("utf8"); } +function createFetchBodyTooLargeError(byteLimit) { + return Object.assign(new Error(`fetch response body exceeded ${byteLimit} bytes`), { + code: "ETOOBIG", + }); +} + function configureKitchenSink(env, port) { const configPath = env.OPENCLAW_CONFIG_PATH; const config = fs.existsSync(configPath) ? readJson(configPath) : {}; diff --git a/test/scripts/kitchen-sink-rpc-walk.test.ts b/test/scripts/kitchen-sink-rpc-walk.test.ts index 57e3d5a4ffc..4403923866f 100644 --- a/test/scripts/kitchen-sink-rpc-walk.test.ts +++ b/test/scripts/kitchen-sink-rpc-walk.test.ts @@ -636,6 +636,56 @@ describe("kitchen-sink RPC process sampling", () => { }); }); + it("rejects oversized HTTP probe responses before reading declared large bodies", async () => { + let canceled = false; + const response = new Response( + new ReadableStream({ + cancel() { + canceled = true; + }, + }), + { + headers: { + "content-length": "1025", + }, + }, + ); + + await expect(readBoundedResponseText(response, 1024)).rejects.toMatchObject({ + code: "ETOOBIG", + message: "fetch response body exceeded 1024 bytes", + }); + expect(canceled).toBe(true); + }); + + it("bounds HTTP probe response bodies without a readable stream", async () => { + const response = { + headers: new Headers(), + text: vi.fn(async () => "x".repeat(1025)), + }; + + await expect(readBoundedResponseText(response, 1024)).rejects.toMatchObject({ + code: "ETOOBIG", + message: "fetch response body exceeded 1024 bytes", + }); + expect(response.text).toHaveBeenCalledTimes(1); + }); + + it("rejects declared large HTTP probe responses without a readable stream", async () => { + const response = { + headers: new Headers({ + "content-length": "1025", + }), + text: vi.fn(async () => "not read"), + }; + + await expect(readBoundedResponseText(response, 1024)).rejects.toMatchObject({ + code: "ETOOBIG", + message: "fetch response body exceeded 1024 bytes", + }); + expect(response.text).not.toHaveBeenCalled(); + }); + it("reads bounded response streams", async () => { await expect(readBoundedResponseText(new Response('{"status":"live"}'), 1024)).resolves.toBe( '{"status":"live"}',