diff --git a/CHANGELOG.md b/CHANGELOG.md index f7ff8491f87..c93dd267c1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,6 +61,7 @@ Docs: https://docs.openclaw.ai - Release/CI/E2E: bound mock OpenAI readiness probes in web-search and Telegram RTT Docker smokes so stalled HTTP accepts cannot hang cleanup or fall through. - Tooling: cancel oversized pnpm audit advisory responses before failing so registry error paths do not leave response bodies open. - Release/CI/E2E: stop tracked gateway and mock service process groups so descendant helpers do not survive E2E cleanup. +- Release/CI/E2E: reject oversized ClickClack fixture request bodies before release journey smokes can accumulate unbounded payloads. - Release/CI/E2E: fail secret-provider proof runs when temporary state cleanup still fails after retries instead of hiding the cleanup error. - Release/CI/E2E: fail package-candidate ref proofs when temporary source worktree cleanup fails instead of leaving stale worktrees behind. - Release/CI/E2E: remove package tarball extract directories when tar extraction fails before validation can continue. diff --git a/scripts/e2e/lib/release-user-journey/clickclack-fixture.mjs b/scripts/e2e/lib/release-user-journey/clickclack-fixture.mjs index 7e4e1441cc8..d3996554a88 100644 --- a/scripts/e2e/lib/release-user-journey/clickclack-fixture.mjs +++ b/scripts/e2e/lib/release-user-journey/clickclack-fixture.mjs @@ -4,6 +4,7 @@ import http from "node:http"; import { readPositiveIntEnv } from "../env-limits.mjs"; const port = readPositiveIntEnv("CLICKCLACK_FIXTURE_PORT", 44181); +const requestMaxBytes = readPositiveIntEnv("CLICKCLACK_FIXTURE_REQUEST_MAX_BYTES", 4 * 1024 * 1024); const token = process.env.CLICKCLACK_FIXTURE_TOKEN ?? "clickclack-release-token"; const statePath = process.env.CLICKCLACK_FIXTURE_STATE ?? "/tmp/openclaw-clickclack-fixture.json"; const workspace = { @@ -86,21 +87,65 @@ function checkAuth(req, res) { function readBody(req) { return new Promise((resolve, reject) => { let body = ""; + let bytes = 0; + let settled = false; req.setEncoding("utf8"); req.on("data", (chunk) => { + if (settled) { + return; + } + bytes += Buffer.byteLength(chunk, "utf8"); + if (bytes > requestMaxBytes) { + settled = true; + body = ""; + req.resume(); + reject(requestBodyTooLargeError()); + return; + } body += chunk; }); req.on("end", () => { + if (settled) { + return; + } + settled = true; try { resolve(body ? JSON.parse(body) : {}); } catch { resolve({}); } }); - req.on("error", reject); + req.on("error", (error) => { + if (!settled) { + settled = true; + reject(error instanceof Error ? error : new Error(String(error))); + } + }); }); } +function requestBodyTooLargeError() { + return Object.assign(new Error(`ClickClack fixture request body exceeded ${requestMaxBytes} bytes`), { + code: "ETOOBIG", + }); +} + +function isRequestBodyTooLargeError(error) { + return error instanceof Error && error.code === "ETOOBIG"; +} + +function handleRequestError(res, error) { + if (res.headersSent) { + res.destroy(); + return; + } + if (isRequestBodyTooLargeError(error)) { + json(res, 413, { error: error.message }); + return; + } + json(res, 500, { error: String(error instanceof Error ? error.message : error) }); +} + function createMessage({ body, author = humanUser, parentMessageId }) { messageSeq += 1; const id = `msg_${messageSeq}`; @@ -171,8 +216,8 @@ function broadcast(event) { } } -const server = http.createServer((req, res) => { - void (async () => { +async function handleRequest(req, res) { + try { const url = new URL(req.url ?? "/", "http://127.0.0.1"); if (!checkAuth(req, res)) { return; @@ -244,7 +289,13 @@ const server = http.createServer((req, res) => { return; } json(res, 404, { error: `unhandled ${req.method} ${url.pathname}` }); - })(); + } catch (error) { + handleRequestError(res, error); + } +} + +const server = http.createServer((req, res) => { + void handleRequest(req, res); }); server.on("upgrade", (req, socket) => { diff --git a/test/scripts/e2e-helper-env-limits.test.ts b/test/scripts/e2e-helper-env-limits.test.ts index dcbd79c976f..ed750130545 100644 --- a/test/scripts/e2e-helper-env-limits.test.ts +++ b/test/scripts/e2e-helper-env-limits.test.ts @@ -1,5 +1,8 @@ import { spawn, spawnSync } from "node:child_process"; +import fs from "node:fs"; import { createServer, type Server } from "node:http"; +import os from "node:os"; +import path from "node:path"; import { describe, expect, it } from "vitest"; const browserFixturePath = "scripts/e2e/lib/browser-cdp-snapshot/fixture-server.mjs"; @@ -57,6 +60,45 @@ async function listen(server: Server): Promise { return `http://127.0.0.1:${address.port}`; } +async function allocatePort(): Promise { + const server = createServer(); + const url = await listen(server); + await new Promise((resolve) => server.close(() => resolve())); + return Number(new URL(url).port); +} + +async function waitForOutput( + child: ReturnType, + matches: (text: string) => boolean, + getOutput: () => string, +): Promise { + const startedAt = Date.now(); + while (Date.now() - startedAt < 3_000) { + if (matches(getOutput())) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 20)); + } + throw new Error(`timed out waiting for fixture output. Output: ${getOutput()}`); +} + +async function stopChild(child: ReturnType): Promise { + if (child.exitCode !== null || child.signalCode !== null) { + return; + } + child.kill("SIGTERM"); + await new Promise((resolve) => { + const timer = setTimeout(() => { + child.kill("SIGKILL"); + resolve(); + }, 1_000); + child.once("exit", () => { + clearTimeout(timer); + resolve(); + }); + }); +} + describe("e2e helper numeric env limits", () => { it("rejects loose Browser CDP fixture ports", async () => { const result = await runScriptAsync(browserFixturePath, [], { FIXTURE_PORT: "18080http" }); @@ -74,6 +116,49 @@ describe("e2e helper numeric env limits", () => { expect(result.stderr).toContain("invalid CLICKCLACK_FIXTURE_PORT: 44181tcp"); }); + it("rejects oversized ClickClack fixture request bodies", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-clickclack-fixture-")); + const port = await allocatePort(); + const child = spawn(process.execPath, [clickclackFixturePath], { + env: { + ...process.env, + CLICKCLACK_FIXTURE_PORT: String(port), + CLICKCLACK_FIXTURE_REQUEST_MAX_BYTES: "16", + CLICKCLACK_FIXTURE_STATE: path.join(tempDir, "state.json"), + }, + stdio: ["ignore", "pipe", "pipe"], + }); + let output = ""; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + output += chunk; + }); + child.stderr.on("data", (chunk) => { + output += chunk; + }); + try { + await waitForOutput( + child, + (text) => text.includes(`clickclack fixture listening on ${port}`), + () => output, + ); + + const response = await fetch(`http://127.0.0.1:${port}/fixture/inbound`, { + body: JSON.stringify({ body: "x".repeat(64) }), + headers: { "content-type": "application/json" }, + method: "POST", + }); + const body = await response.json(); + + expect(response.status).toBe(413); + expect(body).toEqual({ error: "ClickClack fixture request body exceeded 16 bytes" }); + } finally { + await stopChild(child); + fs.rmSync(tempDir, { force: true, recursive: true }); + } + }); + it("rejects loose Open WebUI HTTP probe timeouts", () => { const result = runScript(httpProbePath, ["http://127.0.0.1:9"], { OPENCLAW_HTTP_PROBE_TIMEOUT_MS: "8000ms",