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

116
src/infra/http-body.test.ts Normal file
View File

@@ -0,0 +1,116 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import { EventEmitter } from "node:events";
import { describe, expect, it } from "vitest";
import {
installRequestBodyLimitGuard,
isRequestBodyLimitError,
readJsonBodyWithLimit,
readRequestBodyWithLimit,
} from "./http-body.js";
function createMockRequest(params: {
chunks?: string[];
headers?: Record<string, string>;
emitEnd?: boolean;
}): IncomingMessage {
const req = new EventEmitter() as IncomingMessage & { destroyed?: boolean; destroy: () => void };
req.destroyed = false;
req.headers = params.headers ?? {};
req.destroy = () => {
req.destroyed = true;
};
if (params.chunks) {
void Promise.resolve().then(() => {
for (const chunk of params.chunks ?? []) {
req.emit("data", Buffer.from(chunk, "utf-8"));
if (req.destroyed) {
return;
}
}
if (params.emitEnd !== false) {
req.emit("end");
}
});
}
return req;
}
function createMockResponse(): ServerResponse & { body?: string } {
const headers: Record<string, string> = {};
const res = {
headersSent: false,
statusCode: 200,
setHeader: (key: string, value: string) => {
headers[key.toLowerCase()] = value;
return res;
},
end: (body?: string) => {
res.headersSent = true;
res.body = body;
return res;
},
} as unknown as ServerResponse & { body?: string };
return res;
}
describe("http body limits", () => {
it("reads body within max bytes", async () => {
const req = createMockRequest({ chunks: ['{"ok":true}'] });
await expect(readRequestBodyWithLimit(req, { maxBytes: 1024 })).resolves.toBe('{"ok":true}');
});
it("rejects oversized body", async () => {
const req = createMockRequest({ chunks: ["x".repeat(512)] });
await expect(readRequestBodyWithLimit(req, { maxBytes: 64 })).rejects.toMatchObject({
message: "PayloadTooLarge",
});
});
it("returns json parse error when body is invalid", async () => {
const req = createMockRequest({ chunks: ["{bad json"] });
const result = await readJsonBodyWithLimit(req, { maxBytes: 1024, emptyObjectOnEmpty: false });
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.code).toBe("INVALID_JSON");
}
});
it("returns payload-too-large for json body", async () => {
const req = createMockRequest({ chunks: ["x".repeat(1024)] });
const result = await readJsonBodyWithLimit(req, { maxBytes: 10 });
expect(result).toEqual({ ok: false, code: "PAYLOAD_TOO_LARGE", error: "Payload too large" });
});
it("guard rejects oversized declared content-length", () => {
const req = createMockRequest({
headers: { "content-length": "9999" },
emitEnd: false,
});
const res = createMockResponse();
const guard = installRequestBodyLimitGuard(req, res, { maxBytes: 128 });
expect(guard.isTripped()).toBe(true);
expect(guard.code()).toBe("PAYLOAD_TOO_LARGE");
expect(res.statusCode).toBe(413);
});
it("guard rejects streamed oversized body", async () => {
const req = createMockRequest({ chunks: ["small", "x".repeat(256)], emitEnd: false });
const res = createMockResponse();
const guard = installRequestBodyLimitGuard(req, res, { maxBytes: 128, responseFormat: "text" });
await new Promise((resolve) => setTimeout(resolve, 0));
expect(guard.isTripped()).toBe(true);
expect(guard.code()).toBe("PAYLOAD_TOO_LARGE");
expect(res.statusCode).toBe(413);
expect(res.body).toBe("Payload too large");
});
it("timeout surfaces typed error", async () => {
const req = createMockRequest({ emitEnd: false });
const promise = readRequestBodyWithLimit(req, { maxBytes: 128, timeoutMs: 10 });
await expect(promise).rejects.toSatisfy((error: unknown) =>
isRequestBodyLimitError(error, "REQUEST_BODY_TIMEOUT"),
);
});
});

347
src/infra/http-body.ts Normal file
View File

@@ -0,0 +1,347 @@
import type { IncomingMessage, ServerResponse } from "node:http";
export const DEFAULT_WEBHOOK_MAX_BODY_BYTES = 1024 * 1024;
export const DEFAULT_WEBHOOK_BODY_TIMEOUT_MS = 30_000;
export type RequestBodyLimitErrorCode =
| "PAYLOAD_TOO_LARGE"
| "REQUEST_BODY_TIMEOUT"
| "CONNECTION_CLOSED";
type RequestBodyLimitErrorInit = {
code: RequestBodyLimitErrorCode;
message?: string;
};
const DEFAULT_ERROR_MESSAGE: Record<RequestBodyLimitErrorCode, string> = {
PAYLOAD_TOO_LARGE: "PayloadTooLarge",
REQUEST_BODY_TIMEOUT: "RequestBodyTimeout",
CONNECTION_CLOSED: "RequestBodyConnectionClosed",
};
const DEFAULT_ERROR_STATUS_CODE: Record<RequestBodyLimitErrorCode, number> = {
PAYLOAD_TOO_LARGE: 413,
REQUEST_BODY_TIMEOUT: 408,
CONNECTION_CLOSED: 400,
};
const DEFAULT_RESPONSE_MESSAGE: Record<RequestBodyLimitErrorCode, string> = {
PAYLOAD_TOO_LARGE: "Payload too large",
REQUEST_BODY_TIMEOUT: "Request body timeout",
CONNECTION_CLOSED: "Connection closed",
};
export class RequestBodyLimitError extends Error {
readonly code: RequestBodyLimitErrorCode;
readonly statusCode: number;
constructor(init: RequestBodyLimitErrorInit) {
super(init.message ?? DEFAULT_ERROR_MESSAGE[init.code]);
this.name = "RequestBodyLimitError";
this.code = init.code;
this.statusCode = DEFAULT_ERROR_STATUS_CODE[init.code];
}
}
export function isRequestBodyLimitError(
error: unknown,
code?: RequestBodyLimitErrorCode,
): error is RequestBodyLimitError {
if (!(error instanceof RequestBodyLimitError)) {
return false;
}
if (!code) {
return true;
}
return error.code === code;
}
export function requestBodyErrorToText(code: RequestBodyLimitErrorCode): string {
return DEFAULT_RESPONSE_MESSAGE[code];
}
function parseContentLengthHeader(req: IncomingMessage): number | null {
const header = req.headers["content-length"];
const raw = Array.isArray(header) ? header[0] : header;
if (typeof raw !== "string") {
return null;
}
const parsed = Number.parseInt(raw, 10);
if (!Number.isFinite(parsed) || parsed < 0) {
return null;
}
return parsed;
}
export type ReadRequestBodyOptions = {
maxBytes: number;
timeoutMs?: number;
encoding?: BufferEncoding;
};
export async function readRequestBodyWithLimit(
req: IncomingMessage,
options: ReadRequestBodyOptions,
): Promise<string> {
const maxBytes = Number.isFinite(options.maxBytes)
? Math.max(1, Math.floor(options.maxBytes))
: 1;
const timeoutMs =
typeof options.timeoutMs === "number" && Number.isFinite(options.timeoutMs)
? Math.max(1, Math.floor(options.timeoutMs))
: DEFAULT_WEBHOOK_BODY_TIMEOUT_MS;
const encoding = options.encoding ?? "utf-8";
const declaredLength = parseContentLengthHeader(req);
if (declaredLength !== null && declaredLength > maxBytes) {
const error = new RequestBodyLimitError({ code: "PAYLOAD_TOO_LARGE" });
if (!req.destroyed) {
req.destroy(error);
}
throw error;
}
return await new Promise((resolve, reject) => {
let done = false;
let ended = false;
let totalBytes = 0;
const chunks: Buffer[] = [];
const cleanup = () => {
req.removeListener("data", onData);
req.removeListener("end", onEnd);
req.removeListener("error", onError);
req.removeListener("close", onClose);
clearTimeout(timer);
};
const finish = (cb: () => void) => {
if (done) {
return;
}
done = true;
cleanup();
cb();
};
const fail = (error: RequestBodyLimitError | Error) => {
finish(() => reject(error));
};
const timer = setTimeout(() => {
const error = new RequestBodyLimitError({ code: "REQUEST_BODY_TIMEOUT" });
if (!req.destroyed) {
req.destroy(error);
}
fail(error);
}, timeoutMs);
const onData = (chunk: Buffer | string) => {
if (done) {
return;
}
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
totalBytes += buffer.length;
if (totalBytes > maxBytes) {
const error = new RequestBodyLimitError({ code: "PAYLOAD_TOO_LARGE" });
if (!req.destroyed) {
req.destroy(error);
}
fail(error);
return;
}
chunks.push(buffer);
};
const onEnd = () => {
ended = true;
finish(() => resolve(Buffer.concat(chunks).toString(encoding)));
};
const onError = (error: Error) => {
if (done) {
return;
}
fail(error);
};
const onClose = () => {
if (done || ended) {
return;
}
fail(new RequestBodyLimitError({ code: "CONNECTION_CLOSED" }));
};
req.on("data", onData);
req.on("end", onEnd);
req.on("error", onError);
req.on("close", onClose);
});
}
export type ReadJsonBodyResult =
| { ok: true; value: unknown }
| { ok: false; error: string; code: RequestBodyLimitErrorCode | "INVALID_JSON" };
export type ReadJsonBodyOptions = ReadRequestBodyOptions & {
emptyObjectOnEmpty?: boolean;
};
export async function readJsonBodyWithLimit(
req: IncomingMessage,
options: ReadJsonBodyOptions,
): Promise<ReadJsonBodyResult> {
try {
const raw = await readRequestBodyWithLimit(req, options);
const trimmed = raw.trim();
if (!trimmed) {
if (options.emptyObjectOnEmpty === false) {
return { ok: false, code: "INVALID_JSON", error: "empty payload" };
}
return { ok: true, value: {} };
}
try {
return { ok: true, value: JSON.parse(trimmed) as unknown };
} catch (error) {
return {
ok: false,
code: "INVALID_JSON",
error: error instanceof Error ? error.message : String(error),
};
}
} catch (error) {
if (isRequestBodyLimitError(error)) {
return { ok: false, code: error.code, error: requestBodyErrorToText(error.code) };
}
return {
ok: false,
code: "INVALID_JSON",
error: error instanceof Error ? error.message : String(error),
};
}
}
export type RequestBodyLimitGuard = {
dispose: () => void;
isTripped: () => boolean;
code: () => RequestBodyLimitErrorCode | null;
};
export type RequestBodyLimitGuardOptions = {
maxBytes: number;
timeoutMs?: number;
responseFormat?: "json" | "text";
responseText?: Partial<Record<RequestBodyLimitErrorCode, string>>;
};
export function installRequestBodyLimitGuard(
req: IncomingMessage,
res: ServerResponse,
options: RequestBodyLimitGuardOptions,
): RequestBodyLimitGuard {
const maxBytes = Number.isFinite(options.maxBytes)
? Math.max(1, Math.floor(options.maxBytes))
: 1;
const timeoutMs =
typeof options.timeoutMs === "number" && Number.isFinite(options.timeoutMs)
? Math.max(1, Math.floor(options.timeoutMs))
: DEFAULT_WEBHOOK_BODY_TIMEOUT_MS;
const responseFormat = options.responseFormat ?? "json";
const customText = options.responseText ?? {};
let tripped = false;
let reason: RequestBodyLimitErrorCode | null = null;
let done = false;
let ended = false;
let totalBytes = 0;
const cleanup = () => {
req.removeListener("data", onData);
req.removeListener("end", onEnd);
req.removeListener("close", onClose);
req.removeListener("error", onError);
clearTimeout(timer);
};
const finish = () => {
if (done) {
return;
}
done = true;
cleanup();
};
const respond = (error: RequestBodyLimitError) => {
const text = customText[error.code] ?? requestBodyErrorToText(error.code);
if (!res.headersSent) {
res.statusCode = error.statusCode;
if (responseFormat === "text") {
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end(text);
} else {
res.setHeader("Content-Type", "application/json; charset=utf-8");
res.end(JSON.stringify({ error: text }));
}
}
};
const trip = (error: RequestBodyLimitError) => {
if (tripped) {
return;
}
tripped = true;
reason = error.code;
finish();
respond(error);
if (!req.destroyed) {
req.destroy(error);
}
};
const onData = (chunk: Buffer | string) => {
if (done) {
return;
}
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
totalBytes += buffer.length;
if (totalBytes > maxBytes) {
trip(new RequestBodyLimitError({ code: "PAYLOAD_TOO_LARGE" }));
}
};
const onEnd = () => {
ended = true;
finish();
};
const onClose = () => {
if (done || ended) {
return;
}
finish();
};
const onError = () => {
finish();
};
const timer = setTimeout(() => {
trip(new RequestBodyLimitError({ code: "REQUEST_BODY_TIMEOUT" }));
}, timeoutMs);
req.on("data", onData);
req.on("end", onEnd);
req.on("close", onClose);
req.on("error", onError);
const declaredLength = parseContentLengthHeader(req);
if (declaredLength !== null && declaredLength > maxBytes) {
trip(new RequestBodyLimitError({ code: "PAYLOAD_TOO_LARGE" }));
}
return {
dispose: finish,
isTripped: () => tripped,
code: () => reason,
};
}