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

@@ -0,0 +1,38 @@
import type { IncomingMessage } from "node:http";
import { EventEmitter } from "node:events";
import { describe, expect, it } from "vitest";
import { readLineWebhookRequestBody } from "./monitor.js";
function createMockRequest(chunks: string[]): IncomingMessage {
const req = new EventEmitter() as IncomingMessage & { destroyed?: boolean; destroy: () => void };
req.destroyed = false;
req.headers = {};
req.destroy = () => {
req.destroyed = true;
};
void Promise.resolve().then(() => {
for (const chunk of chunks) {
req.emit("data", Buffer.from(chunk, "utf-8"));
if (req.destroyed) {
return;
}
}
req.emit("end");
});
return req;
}
describe("readLineWebhookRequestBody", () => {
it("reads body within limit", async () => {
const req = createMockRequest(['{"events":[{"type":"message"}]}']);
const body = await readLineWebhookRequestBody(req, 1024);
expect(body).toContain('"events"');
});
it("rejects oversized body", async () => {
const req = createMockRequest(["x".repeat(2048)]);
await expect(readLineWebhookRequestBody(req, 128)).rejects.toThrow("PayloadTooLarge");
});
});

View File

@@ -7,6 +7,11 @@ import { chunkMarkdownText } from "../auto-reply/chunk.js";
import { dispatchReplyWithBufferedBlockDispatcher } from "../auto-reply/reply/provider-dispatcher.js";
import { createReplyPrefixOptions } from "../channels/reply-prefix.js";
import { danger, logVerbose } from "../globals.js";
import {
isRequestBodyLimitError,
readRequestBodyWithLimit,
requestBodyErrorToText,
} from "../infra/http-body.js";
import { normalizePluginHttpPath } from "../plugins/http-path.js";
import { registerPluginHttpRoute } from "../plugins/http-registry.js";
import { deliverLineAutoReply } from "./auto-reply-delivery.js";
@@ -46,6 +51,9 @@ export interface LineProviderMonitor {
stop: () => void;
}
const LINE_WEBHOOK_MAX_BODY_BYTES = 1024 * 1024;
const LINE_WEBHOOK_BODY_TIMEOUT_MS = 30_000;
// Track runtime state in memory (simplified version)
const runtimeState = new Map<
string,
@@ -85,12 +93,13 @@ export function getLineRuntimeState(accountId: string) {
return runtimeState.get(`line:${accountId}`);
}
async function readRequestBody(req: IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
req.on("data", (chunk) => chunks.push(chunk));
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
req.on("error", reject);
export async function readLineWebhookRequestBody(
req: IncomingMessage,
maxBytes = LINE_WEBHOOK_MAX_BODY_BYTES,
): Promise<string> {
return await readRequestBodyWithLimit(req, {
maxBytes,
timeoutMs: LINE_WEBHOOK_BODY_TIMEOUT_MS,
});
}
@@ -310,7 +319,7 @@ export async function monitorLineProvider(
}
try {
const rawBody = await readRequestBody(req);
const rawBody = await readLineWebhookRequestBody(req, LINE_WEBHOOK_MAX_BODY_BYTES);
const signature = req.headers["x-line-signature"];
// Validate signature
@@ -346,6 +355,18 @@ export async function monitorLineProvider(
});
}
} catch (err) {
if (isRequestBodyLimitError(err, "PAYLOAD_TOO_LARGE")) {
res.statusCode = 413;
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({ error: "Payload too large" }));
return;
}
if (isRequestBodyLimitError(err, "REQUEST_BODY_TIMEOUT")) {
res.statusCode = 408;
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({ error: requestBodyErrorToText("REQUEST_BODY_TIMEOUT") }));
return;
}
runtime.error?.(danger(`line webhook error: ${String(err)}`));
if (!res.headersSent) {
res.statusCode = 500;