refactor: split webhook ingress and policy guards

This commit is contained in:
Peter Steinberger
2026-03-02 18:02:10 +00:00
parent fc0d374390
commit 1c9deeda97
11 changed files with 1026 additions and 819 deletions

View File

@@ -125,6 +125,8 @@ export {
registerWebhookTarget,
registerWebhookTargetWithPluginRoute,
rejectNonPostWebhookRequest,
resolveWebhookTargetWithAuthOrReject,
resolveWebhookTargetWithAuthOrRejectSync,
resolveSingleWebhookTarget,
resolveSingleWebhookTargetAsync,
resolveWebhookTargets,

View File

@@ -55,6 +55,32 @@ function resolveWebhookBodyReadLimits(params: {
return { maxBytes, timeoutMs };
}
function respondWebhookBodyReadError(params: {
res: ServerResponse;
code: string;
invalidMessage?: string;
}): { ok: false } {
const { res, code, invalidMessage } = params;
if (code === "PAYLOAD_TOO_LARGE") {
res.statusCode = 413;
res.end(requestBodyErrorToText("PAYLOAD_TOO_LARGE"));
return { ok: false };
}
if (code === "REQUEST_BODY_TIMEOUT") {
res.statusCode = 408;
res.end(requestBodyErrorToText("REQUEST_BODY_TIMEOUT"));
return { ok: false };
}
if (code === "CONNECTION_CLOSED") {
res.statusCode = 400;
res.end(requestBodyErrorToText("CONNECTION_CLOSED"));
return { ok: false };
}
res.statusCode = 400;
res.end(invalidMessage ?? "Bad Request");
return { ok: false };
}
export function createWebhookInFlightLimiter(options?: {
maxInFlightPerKey?: number;
maxTrackedKeys?: number;
@@ -219,20 +245,18 @@ export async function readWebhookBodyOrReject(params: {
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 };
return respondWebhookBodyReadError({
res: params.res,
code: error.code,
invalidMessage: params.invalidBodyMessage,
});
}
params.res.statusCode = 400;
params.res.end(
params.invalidBodyMessage ?? (error instanceof Error ? error.message : String(error)),
);
return { ok: false };
return respondWebhookBodyReadError({
res: params.res,
code: "INVALID_BODY",
invalidMessage:
params.invalidBodyMessage ?? (error instanceof Error ? error.message : String(error)),
});
}
}
@@ -258,15 +282,9 @@ export async function readJsonWebhookBodyOrReject(params: {
if (body.ok) {
return { ok: true, value: body.value };
}
params.res.statusCode =
body.code === "PAYLOAD_TOO_LARGE" ? 413 : body.code === "REQUEST_BODY_TIMEOUT" ? 408 : 400;
const message =
body.code === "PAYLOAD_TOO_LARGE"
? requestBodyErrorToText("PAYLOAD_TOO_LARGE")
: body.code === "REQUEST_BODY_TIMEOUT"
? requestBodyErrorToText("REQUEST_BODY_TIMEOUT")
: (params.invalidJsonMessage ?? "Bad Request");
params.res.end(message);
return { ok: false };
return respondWebhookBodyReadError({
res: params.res,
code: body.code,
invalidMessage: params.invalidJsonMessage,
});
}

View File

@@ -9,6 +9,8 @@ import {
rejectNonPostWebhookRequest,
resolveSingleWebhookTarget,
resolveSingleWebhookTargetAsync,
resolveWebhookTargetWithAuthOrReject,
resolveWebhookTargetWithAuthOrRejectSync,
resolveWebhookTargets,
} from "./webhook-targets.js";
@@ -212,3 +214,72 @@ describe("resolveSingleWebhookTarget", () => {
expect(calls).toEqual(["a", "b"]);
});
});
describe("resolveWebhookTargetWithAuthOrReject", () => {
it("returns matched target", async () => {
const res = {
statusCode: 200,
setHeader: vi.fn(),
end: vi.fn(),
} as unknown as ServerResponse;
await expect(
resolveWebhookTargetWithAuthOrReject({
targets: [{ id: "a" }, { id: "b" }],
res,
isMatch: (target) => target.id === "b",
}),
).resolves.toEqual({ id: "b" });
});
it("writes unauthorized response on no match", async () => {
const endMock = vi.fn();
const res = {
statusCode: 200,
setHeader: vi.fn(),
end: endMock,
} as unknown as ServerResponse;
await expect(
resolveWebhookTargetWithAuthOrReject({
targets: [{ id: "a" }],
res,
isMatch: () => false,
}),
).resolves.toBeNull();
expect(res.statusCode).toBe(401);
expect(endMock).toHaveBeenCalledWith("unauthorized");
});
it("writes ambiguous response on multi-match", async () => {
const endMock = vi.fn();
const res = {
statusCode: 200,
setHeader: vi.fn(),
end: endMock,
} as unknown as ServerResponse;
await expect(
resolveWebhookTargetWithAuthOrReject({
targets: [{ id: "a" }, { id: "b" }],
res,
isMatch: () => true,
}),
).resolves.toBeNull();
expect(res.statusCode).toBe(401);
expect(endMock).toHaveBeenCalledWith("ambiguous webhook target");
});
});
describe("resolveWebhookTargetWithAuthOrRejectSync", () => {
it("returns matched target synchronously", () => {
const res = {
statusCode: 200,
setHeader: vi.fn(),
end: vi.fn(),
} as unknown as ServerResponse;
const target = resolveWebhookTargetWithAuthOrRejectSync({
targets: [{ id: "a" }, { id: "b" }],
res,
isMatch: (entry) => entry.id === "a",
});
expect(target).toEqual({ id: "a" });
});
});

View File

@@ -152,6 +152,57 @@ export async function resolveSingleWebhookTargetAsync<T>(
return { kind: "single", target: matched };
}
export async function resolveWebhookTargetWithAuthOrReject<T>(params: {
targets: readonly T[];
res: ServerResponse;
isMatch: (target: T) => boolean | Promise<boolean>;
unauthorizedStatusCode?: number;
unauthorizedMessage?: string;
ambiguousStatusCode?: number;
ambiguousMessage?: string;
}): Promise<T | null> {
const match = await resolveSingleWebhookTargetAsync(params.targets, async (target) =>
Boolean(await params.isMatch(target)),
);
return resolveWebhookTargetMatchOrReject(params, match);
}
export function resolveWebhookTargetWithAuthOrRejectSync<T>(params: {
targets: readonly T[];
res: ServerResponse;
isMatch: (target: T) => boolean;
unauthorizedStatusCode?: number;
unauthorizedMessage?: string;
ambiguousStatusCode?: number;
ambiguousMessage?: string;
}): T | null {
const match = resolveSingleWebhookTarget(params.targets, params.isMatch);
return resolveWebhookTargetMatchOrReject(params, match);
}
function resolveWebhookTargetMatchOrReject<T>(
params: {
res: ServerResponse;
unauthorizedStatusCode?: number;
unauthorizedMessage?: string;
ambiguousStatusCode?: number;
ambiguousMessage?: string;
},
match: WebhookTargetMatchResult<T>,
): T | null {
if (match.kind === "single") {
return match.target;
}
if (match.kind === "ambiguous") {
params.res.statusCode = params.ambiguousStatusCode ?? 401;
params.res.end(params.ambiguousMessage ?? "ambiguous webhook target");
return null;
}
params.res.statusCode = params.unauthorizedStatusCode ?? 401;
params.res.end(params.unauthorizedMessage ?? "unauthorized");
return null;
}
export function rejectNonPostWebhookRequest(req: IncomingMessage, res: ServerResponse): boolean {
if (req.method === "POST") {
return false;