fix(security): enforce bounded webhook body handling

This commit is contained in:
Peter Steinberger
2026-02-13 19:14:36 +01:00
parent 2f9c523bbe
commit 3cbcba10cf
20 changed files with 834 additions and 281 deletions

View File

@@ -4,6 +4,7 @@ import type { ChannelId } from "../channels/plugins/types.js";
import type { OpenClawConfig } from "../config/config.js";
import { listAgentIds, resolveDefaultAgentId } from "../agents/agent-scope.js";
import { listChannelPlugins } from "../channels/plugins/index.js";
import { readJsonBodyWithLimit, requestBodyErrorToText } from "../infra/http-body.js";
import { normalizeAgentId } from "../routing/session-key.js";
import { normalizeMessageChannel } from "../utils/message-channel.js";
import { type HookMappingResolved, resolveHookMappings } from "./hooks-mapping.js";
@@ -177,48 +178,20 @@ export async function readJsonBody(
req: IncomingMessage,
maxBytes: number,
): Promise<{ ok: true; value: unknown } | { ok: false; error: string }> {
return await new Promise((resolve) => {
let done = false;
let total = 0;
const chunks: Buffer[] = [];
req.on("data", (chunk: Buffer) => {
if (done) {
return;
}
total += chunk.length;
if (total > maxBytes) {
done = true;
resolve({ ok: false, error: "payload too large" });
req.destroy();
return;
}
chunks.push(chunk);
});
req.on("end", () => {
if (done) {
return;
}
done = true;
const raw = Buffer.concat(chunks).toString("utf-8").trim();
if (!raw) {
resolve({ ok: true, value: {} });
return;
}
try {
const parsed = JSON.parse(raw) as unknown;
resolve({ ok: true, value: parsed });
} catch (err) {
resolve({ ok: false, error: String(err) });
}
});
req.on("error", (err) => {
if (done) {
return;
}
done = true;
resolve({ ok: false, error: String(err) });
});
});
const result = await readJsonBodyWithLimit(req, { maxBytes, emptyObjectOnEmpty: true });
if (result.ok) {
return result;
}
if (result.code === "PAYLOAD_TOO_LARGE") {
return { ok: false, error: "payload too large" };
}
if (result.code === "REQUEST_BODY_TIMEOUT") {
return { ok: false, error: "request body timeout" };
}
if (result.code === "CONNECTION_CLOSED") {
return { ok: false, error: requestBodyErrorToText("CONNECTION_CLOSED") };
}
return { ok: false, error: result.error };
}
export function normalizeHookHeaders(req: IncomingMessage) {

View File

@@ -58,6 +58,18 @@ export async function readJsonBodyOrError(
): Promise<unknown> {
const body = await readJsonBody(req, maxBytes);
if (!body.ok) {
if (body.error === "payload too large") {
sendJson(res, 413, {
error: { message: "Payload too large", type: "invalid_request_error" },
});
return undefined;
}
if (body.error === "request body timeout") {
sendJson(res, 408, {
error: { message: "Request body timeout", type: "invalid_request_error" },
});
return undefined;
}
sendInvalidRequest(res, body.error);
return undefined;
}

View File

@@ -287,7 +287,12 @@ export function createHooksRequestHandler(
const body = await readJsonBody(req, hooksConfig.maxBodyBytes);
if (!body.ok) {
const status = body.error === "payload too large" ? 413 : 400;
const status =
body.error === "payload too large"
? 413
: body.error === "request body timeout"
? 408
: 400;
sendJson(res, status, { ok: false, error: body.error });
return true;
}