mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-05 03:50:20 +00:00
refactor: split webhook ingress and policy guards
This commit is contained in:
@@ -125,6 +125,8 @@ export {
|
||||
registerWebhookTarget,
|
||||
registerWebhookTargetWithPluginRoute,
|
||||
rejectNonPostWebhookRequest,
|
||||
resolveWebhookTargetWithAuthOrReject,
|
||||
resolveWebhookTargetWithAuthOrRejectSync,
|
||||
resolveSingleWebhookTarget,
|
||||
resolveSingleWebhookTargetAsync,
|
||||
resolveWebhookTargets,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user