diff --git a/scripts/e2e/lib/plugins/assertions.mjs b/scripts/e2e/lib/plugins/assertions.mjs index 1adb871075d..58285112aa9 100644 --- a/scripts/e2e/lib/plugins/assertions.mjs +++ b/scripts/e2e/lib/plugins/assertions.mjs @@ -8,6 +8,10 @@ const CLAWHUB_PREFLIGHT_TIMEOUT_MS = readPositiveInt( process.env.OPENCLAW_PLUGINS_E2E_CLAWHUB_PREFLIGHT_TIMEOUT_MS, 30_000, ); +const CLAWHUB_PREFLIGHT_BODY_MAX_BYTES = readPositiveInt( + process.env.OPENCLAW_PLUGINS_E2E_CLAWHUB_PREFLIGHT_BODY_MAX_BYTES, + 1024 * 1024, +); const readJson = (file) => JSON.parse(fs.readFileSync(file, "utf8")); const scratchFile = (name) => path.join(scratchRoot, name); @@ -42,6 +46,47 @@ async function withTimeout(label, timeoutMs, run) { } } +function bodyTooLargeError(label, byteLimit) { + return Object.assign(new Error(`${label} response body exceeded ${byteLimit} bytes`), { + code: "ETOOBIG", + }); +} + +async function readBoundedResponseText(response, label, byteLimit) { + const contentLength = response.headers.get("content-length"); + if (contentLength) { + const parsedLength = Number(contentLength); + if (Number.isSafeInteger(parsedLength) && parsedLength > byteLimit) { + await response.body?.cancel().catch(() => {}); + throw bodyTooLargeError(label, byteLimit); + } + } + if (!response.body) { + return ""; + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let byteCount = 0; + let text = ""; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) { + return text + decoder.decode(); + } + byteCount += value.byteLength; + if (byteCount > byteLimit) { + await reader.cancel().catch(() => {}); + throw bodyTooLargeError(label, byteLimit); + } + text += decoder.decode(value, { stream: true }); + } + } finally { + reader.releaseLock(); + } +} + function resolveHomePath(value) { if (value === "~") { return process.env.HOME; @@ -806,16 +851,31 @@ async function assertClawHubPreflight() { const body = await withTimeout( `ClawHub package preflight response for ${packageName}`, CLAWHUB_PREFLIGHT_TIMEOUT_MS, - () => response.text().catch(() => ""), + () => + readBoundedResponseText( + response, + `ClawHub package preflight response for ${packageName}`, + CLAWHUB_PREFLIGHT_BODY_MAX_BYTES, + ), ); throw new Error( `ClawHub package preflight failed for ${packageName}: ${response.status} ${body}`, ); } - const detail = await withTimeout( + const rawDetail = await withTimeout( `ClawHub package preflight response for ${packageName}`, CLAWHUB_PREFLIGHT_TIMEOUT_MS, - () => response.json(), + () => + readBoundedResponseText( + response, + `ClawHub package preflight response for ${packageName}`, + CLAWHUB_PREFLIGHT_BODY_MAX_BYTES, + ), + ); + const detail = await withTimeout( + `ClawHub package preflight JSON for ${packageName}`, + CLAWHUB_PREFLIGHT_TIMEOUT_MS, + () => JSON.parse(rawDetail), ); const family = detail.package?.family; if (family !== "code-plugin" && family !== "bundle-plugin") { diff --git a/test/scripts/plugins-assertions.test.ts b/test/scripts/plugins-assertions.test.ts index 14438051dcf..1452753a347 100644 --- a/test/scripts/plugins-assertions.test.ts +++ b/test/scripts/plugins-assertions.test.ts @@ -236,4 +236,38 @@ describe("plugins Docker assertions", () => { }); } }); + + it("bounds ClawHub package metadata response bodies", async () => { + const server = createServer((_request, response) => { + response.writeHead(500, { "content-type": "text/plain" }); + response.end("x".repeat(128)); + }); + await new Promise((resolve) => { + server.listen(0, "127.0.0.1", resolve); + }); + + try { + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("expected TCP server address"); + } + const result = await runAssertionAsync(["clawhub-preflight"], { + CLAWHUB_PLUGIN_ID: "openclaw-kitchen-sink-fixture", + CLAWHUB_PLUGIN_SPEC: "clawhub:@openclaw/kitchen-sink", + OPENCLAW_CLAWHUB_URL: `http://127.0.0.1:${address.port}`, + OPENCLAW_PLUGINS_E2E_CLAWHUB_PREFLIGHT_BODY_MAX_BYTES: "16", + OPENCLAW_PLUGINS_E2E_CLAWHUB_PREFLIGHT_TIMEOUT_MS: "1000", + }); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain( + "ClawHub package preflight response for @openclaw/kitchen-sink response body exceeded 16 bytes", + ); + expect(result.stderr).not.toContain("xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"); + } finally { + await new Promise((resolve) => { + server.close(() => resolve()); + }); + } + }); });