fix: harden webhook auth-before-body handling

This commit is contained in:
Peter Steinberger
2026-03-02 17:20:46 +00:00
parent dded569626
commit d3e8b17aa6
15 changed files with 789 additions and 251 deletions

View File

@@ -126,6 +126,31 @@ describe("createLineNodeWebhookHandler", () => {
expect(bot.handleWebhook).not.toHaveBeenCalled();
});
it("uses strict pre-auth limits for signed POST requests", async () => {
const rawBody = JSON.stringify({ events: [{ type: "message" }] });
const bot = { handleWebhook: vi.fn(async () => {}) };
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
const readBody = vi.fn(async (_req: IncomingMessage, maxBytes: number, timeoutMs?: number) => {
expect(maxBytes).toBe(64 * 1024);
expect(timeoutMs).toBe(5_000);
return rawBody;
});
const handler = createLineNodeWebhookHandler({
channelSecret: "secret",
bot,
runtime,
readBody,
maxBodyBytes: 1024 * 1024,
});
const { res } = createRes();
await runSignedPost({ handler, rawBody, secret: "secret", res });
expect(res.statusCode).toBe(200);
expect(readBody).toHaveBeenCalledTimes(1);
expect(bot.handleWebhook).toHaveBeenCalledTimes(1);
});
it("rejects invalid signature", async () => {
const rawBody = JSON.stringify({ events: [{ type: "message" }] });
const { bot, handler } = createPostWebhookTestHarness(rawBody);

View File

@@ -11,20 +11,22 @@ import { validateLineSignature } from "./signature.js";
import { isLineWebhookVerificationRequest, parseLineWebhookBody } from "./webhook-utils.js";
const LINE_WEBHOOK_MAX_BODY_BYTES = 1024 * 1024;
const LINE_WEBHOOK_PREAUTH_MAX_BODY_BYTES = 64 * 1024;
const LINE_WEBHOOK_UNSIGNED_MAX_BODY_BYTES = 4 * 1024;
const LINE_WEBHOOK_BODY_TIMEOUT_MS = 30_000;
const LINE_WEBHOOK_PREAUTH_BODY_TIMEOUT_MS = 5_000;
export async function readLineWebhookRequestBody(
req: IncomingMessage,
maxBytes = LINE_WEBHOOK_MAX_BODY_BYTES,
timeoutMs = LINE_WEBHOOK_PREAUTH_BODY_TIMEOUT_MS,
): Promise<string> {
return await readRequestBodyWithLimit(req, {
maxBytes,
timeoutMs: LINE_WEBHOOK_BODY_TIMEOUT_MS,
timeoutMs,
});
}
type ReadBodyFn = (req: IncomingMessage, maxBytes: number) => Promise<string>;
type ReadBodyFn = (req: IncomingMessage, maxBytes: number, timeoutMs?: number) => Promise<string>;
export function createLineNodeWebhookHandler(params: {
channelSecret: string;
@@ -64,9 +66,9 @@ export function createLineNodeWebhookHandler(params: {
: undefined;
const hasSignature = typeof signature === "string" && signature.trim().length > 0;
const bodyLimit = hasSignature
? maxBodyBytes
? Math.min(maxBodyBytes, LINE_WEBHOOK_PREAUTH_MAX_BODY_BYTES)
: Math.min(maxBodyBytes, LINE_WEBHOOK_UNSIGNED_MAX_BODY_BYTES);
const rawBody = await readBody(req, bodyLimit);
const rawBody = await readBody(req, bodyLimit, LINE_WEBHOOK_PREAUTH_BODY_TIMEOUT_MS);
// Parse once; we may need it for verification requests and for event processing.
const body = parseLineWebhookBody(rawBody);

View File

@@ -136,9 +136,15 @@ export type {
} from "./webhook-targets.js";
export {
applyBasicWebhookRequestGuards,
beginWebhookRequestPipelineOrReject,
createWebhookInFlightLimiter,
isJsonContentType,
readWebhookBodyOrReject,
readJsonWebhookBodyOrReject,
WEBHOOK_BODY_READ_DEFAULTS,
WEBHOOK_IN_FLIGHT_DEFAULTS,
} from "./webhook-request-guards.js";
export type { WebhookBodyReadProfile, WebhookInFlightLimiter } from "./webhook-request-guards.js";
export type { AgentMediaPayload } from "./agent-media-payload.js";
export { buildAgentMediaPayload } from "./agent-media-payload.js";
export {

View File

@@ -5,7 +5,10 @@ import { createMockServerResponse } from "../test-utils/mock-http-response.js";
import { createFixedWindowRateLimiter } from "./webhook-memory-guards.js";
import {
applyBasicWebhookRequestGuards,
beginWebhookRequestPipelineOrReject,
createWebhookInFlightLimiter,
isJsonContentType,
readWebhookBodyOrReject,
readJsonWebhookBodyOrReject,
} from "./webhook-request-guards.js";
@@ -158,3 +161,76 @@ describe("readJsonWebhookBodyOrReject", () => {
expect(res.body).toBe("Bad Request");
});
});
describe("readWebhookBodyOrReject", () => {
it("returns raw body contents", async () => {
const req = createMockRequest({ chunks: ["plain text"] });
const res = createMockServerResponse();
await expect(
readWebhookBodyOrReject({
req,
res,
}),
).resolves.toEqual({ ok: true, value: "plain text" });
});
it("enforces strict pre-auth default body limits", async () => {
const req = createMockRequest({
headers: { "content-length": String(70 * 1024) },
});
const res = createMockServerResponse();
await expect(
readWebhookBodyOrReject({
req,
res,
profile: "pre-auth",
}),
).resolves.toEqual({ ok: false });
expect(res.statusCode).toBe(413);
});
});
describe("beginWebhookRequestPipelineOrReject", () => {
it("enforces in-flight request limits and releases slots", () => {
const limiter = createWebhookInFlightLimiter({
maxInFlightPerKey: 1,
maxTrackedKeys: 10,
});
const first = beginWebhookRequestPipelineOrReject({
req: createMockRequest({ method: "POST" }),
res: createMockServerResponse(),
allowMethods: ["POST"],
inFlightLimiter: limiter,
inFlightKey: "ip:127.0.0.1",
});
expect(first.ok).toBe(true);
const secondRes = createMockServerResponse();
const second = beginWebhookRequestPipelineOrReject({
req: createMockRequest({ method: "POST" }),
res: secondRes,
allowMethods: ["POST"],
inFlightLimiter: limiter,
inFlightKey: "ip:127.0.0.1",
});
expect(second.ok).toBe(false);
expect(secondRes.statusCode).toBe(429);
if (first.ok) {
first.release();
}
const third = beginWebhookRequestPipelineOrReject({
req: createMockRequest({ method: "POST" }),
res: createMockServerResponse(),
allowMethods: ["POST"],
inFlightLimiter: limiter,
inFlightKey: "ip:127.0.0.1",
});
expect(third.ok).toBe(true);
if (third.ok) {
third.release();
}
});
});

View File

@@ -1,7 +1,106 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import { readJsonBodyWithLimit, requestBodyErrorToText } from "../infra/http-body.js";
import {
isRequestBodyLimitError,
readJsonBodyWithLimit,
readRequestBodyWithLimit,
requestBodyErrorToText,
} from "../infra/http-body.js";
import { pruneMapToMaxSize } from "../infra/map-size.js";
import type { FixedWindowRateLimiter } from "./webhook-memory-guards.js";
export type WebhookBodyReadProfile = "pre-auth" | "post-auth";
export const WEBHOOK_BODY_READ_DEFAULTS = Object.freeze({
preAuth: {
maxBytes: 64 * 1024,
timeoutMs: 5_000,
},
postAuth: {
maxBytes: 1024 * 1024,
timeoutMs: 30_000,
},
});
export const WEBHOOK_IN_FLIGHT_DEFAULTS = Object.freeze({
maxInFlightPerKey: 8,
maxTrackedKeys: 4_096,
});
export type WebhookInFlightLimiter = {
tryAcquire: (key: string) => boolean;
release: (key: string) => void;
size: () => number;
clear: () => void;
};
function resolveWebhookBodyReadLimits(params: {
maxBytes?: number;
timeoutMs?: number;
profile?: WebhookBodyReadProfile;
}): { maxBytes: number; timeoutMs: number } {
const defaults =
params.profile === "pre-auth"
? WEBHOOK_BODY_READ_DEFAULTS.preAuth
: WEBHOOK_BODY_READ_DEFAULTS.postAuth;
const maxBytes =
typeof params.maxBytes === "number" && Number.isFinite(params.maxBytes) && params.maxBytes > 0
? Math.floor(params.maxBytes)
: defaults.maxBytes;
const timeoutMs =
typeof params.timeoutMs === "number" &&
Number.isFinite(params.timeoutMs) &&
params.timeoutMs > 0
? Math.floor(params.timeoutMs)
: defaults.timeoutMs;
return { maxBytes, timeoutMs };
}
export function createWebhookInFlightLimiter(options?: {
maxInFlightPerKey?: number;
maxTrackedKeys?: number;
}): WebhookInFlightLimiter {
const maxInFlightPerKey = Math.max(
1,
Math.floor(options?.maxInFlightPerKey ?? WEBHOOK_IN_FLIGHT_DEFAULTS.maxInFlightPerKey),
);
const maxTrackedKeys = Math.max(
1,
Math.floor(options?.maxTrackedKeys ?? WEBHOOK_IN_FLIGHT_DEFAULTS.maxTrackedKeys),
);
const active = new Map<string, number>();
return {
tryAcquire: (key: string) => {
if (!key) {
return true;
}
const current = active.get(key) ?? 0;
if (current >= maxInFlightPerKey) {
return false;
}
active.set(key, current + 1);
pruneMapToMaxSize(active, maxTrackedKeys);
return true;
},
release: (key: string) => {
if (!key) {
return;
}
const current = active.get(key);
if (current === undefined) {
return;
}
if (current <= 1) {
active.delete(key);
return;
}
active.set(key, current - 1);
},
size: () => active.size,
clear: () => active.clear(),
};
}
export function isJsonContentType(value: string | string[] | undefined): boolean {
const first = Array.isArray(value) ? value[0] : value;
if (!first) {
@@ -51,17 +150,109 @@ export function applyBasicWebhookRequestGuards(params: {
return true;
}
export function beginWebhookRequestPipelineOrReject(params: {
req: IncomingMessage;
res: ServerResponse;
allowMethods?: readonly string[];
rateLimiter?: FixedWindowRateLimiter;
rateLimitKey?: string;
nowMs?: number;
requireJsonContentType?: boolean;
inFlightLimiter?: WebhookInFlightLimiter;
inFlightKey?: string;
inFlightLimitStatusCode?: number;
inFlightLimitMessage?: string;
}): { ok: true; release: () => void } | { ok: false } {
if (
!applyBasicWebhookRequestGuards({
req: params.req,
res: params.res,
allowMethods: params.allowMethods,
rateLimiter: params.rateLimiter,
rateLimitKey: params.rateLimitKey,
nowMs: params.nowMs,
requireJsonContentType: params.requireJsonContentType,
})
) {
return { ok: false };
}
const inFlightKey = params.inFlightKey ?? "";
const inFlightLimiter = params.inFlightLimiter;
if (inFlightLimiter && inFlightKey && !inFlightLimiter.tryAcquire(inFlightKey)) {
params.res.statusCode = params.inFlightLimitStatusCode ?? 429;
params.res.end(params.inFlightLimitMessage ?? "Too Many Requests");
return { ok: false };
}
let released = false;
return {
ok: true,
release: () => {
if (released) {
return;
}
released = true;
if (inFlightLimiter && inFlightKey) {
inFlightLimiter.release(inFlightKey);
}
},
};
}
export async function readWebhookBodyOrReject(params: {
req: IncomingMessage;
res: ServerResponse;
maxBytes?: number;
timeoutMs?: number;
profile?: WebhookBodyReadProfile;
invalidBodyMessage?: string;
}): Promise<{ ok: true; value: string } | { ok: false }> {
const limits = resolveWebhookBodyReadLimits({
maxBytes: params.maxBytes,
timeoutMs: params.timeoutMs,
profile: params.profile,
});
try {
const raw = await readRequestBodyWithLimit(params.req, limits);
return { ok: true, value: raw };
} catch (error) {
if (isRequestBodyLimitError(error)) {
params.res.statusCode =
error.code === "PAYLOAD_TOO_LARGE"
? 413
: error.code === "REQUEST_BODY_TIMEOUT"
? 408
: 400;
params.res.end(requestBodyErrorToText(error.code));
return { ok: false };
}
params.res.statusCode = 400;
params.res.end(
params.invalidBodyMessage ?? (error instanceof Error ? error.message : String(error)),
);
return { ok: false };
}
}
export async function readJsonWebhookBodyOrReject(params: {
req: IncomingMessage;
res: ServerResponse;
maxBytes: number;
maxBytes?: number;
timeoutMs?: number;
profile?: WebhookBodyReadProfile;
emptyObjectOnEmpty?: boolean;
invalidJsonMessage?: string;
}): Promise<{ ok: true; value: unknown } | { ok: false }> {
const body = await readJsonBodyWithLimit(params.req, {
const limits = resolveWebhookBodyReadLimits({
maxBytes: params.maxBytes,
timeoutMs: params.timeoutMs,
profile: params.profile,
});
const body = await readJsonBodyWithLimit(params.req, {
maxBytes: limits.maxBytes,
timeoutMs: limits.timeoutMs,
emptyObjectOnEmpty: params.emptyObjectOnEmpty,
});
if (body.ok) {