mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-19 12:08:37 +00:00
refactor(security): unify webhook auth matching paths
This commit is contained in:
55
extensions/bluebubbles/src/config-schema.test.ts
Normal file
55
extensions/bluebubbles/src/config-schema.test.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { BlueBubblesConfigSchema } from "./config-schema.js";
|
||||||
|
|
||||||
|
describe("BlueBubblesConfigSchema", () => {
|
||||||
|
it("accepts account config when serverUrl and password are both set", () => {
|
||||||
|
const parsed = BlueBubblesConfigSchema.safeParse({
|
||||||
|
serverUrl: "http://localhost:1234",
|
||||||
|
password: "secret",
|
||||||
|
});
|
||||||
|
expect(parsed.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires password when top-level serverUrl is configured", () => {
|
||||||
|
const parsed = BlueBubblesConfigSchema.safeParse({
|
||||||
|
serverUrl: "http://localhost:1234",
|
||||||
|
});
|
||||||
|
expect(parsed.success).toBe(false);
|
||||||
|
if (parsed.success) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
expect(parsed.error.issues[0]?.path).toEqual(["password"]);
|
||||||
|
expect(parsed.error.issues[0]?.message).toBe(
|
||||||
|
"password is required when serverUrl is configured",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires password when account serverUrl is configured", () => {
|
||||||
|
const parsed = BlueBubblesConfigSchema.safeParse({
|
||||||
|
accounts: {
|
||||||
|
work: {
|
||||||
|
serverUrl: "http://localhost:1234",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(parsed.success).toBe(false);
|
||||||
|
if (parsed.success) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
expect(parsed.error.issues[0]?.path).toEqual(["accounts", "work", "password"]);
|
||||||
|
expect(parsed.error.issues[0]?.message).toBe(
|
||||||
|
"password is required when serverUrl is configured",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows password omission when serverUrl is not configured", () => {
|
||||||
|
const parsed = BlueBubblesConfigSchema.safeParse({
|
||||||
|
accounts: {
|
||||||
|
work: {
|
||||||
|
name: "Work iMessage",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(parsed.success).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -24,27 +24,39 @@ const bluebubblesGroupConfigSchema = z.object({
|
|||||||
tools: ToolPolicySchema,
|
tools: ToolPolicySchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
const bluebubblesAccountSchema = z.object({
|
const bluebubblesAccountSchema = z
|
||||||
name: z.string().optional(),
|
.object({
|
||||||
enabled: z.boolean().optional(),
|
name: z.string().optional(),
|
||||||
markdown: MarkdownConfigSchema,
|
enabled: z.boolean().optional(),
|
||||||
serverUrl: z.string().optional(),
|
markdown: MarkdownConfigSchema,
|
||||||
password: z.string().optional(),
|
serverUrl: z.string().optional(),
|
||||||
webhookPath: z.string().optional(),
|
password: z.string().optional(),
|
||||||
dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(),
|
webhookPath: z.string().optional(),
|
||||||
allowFrom: z.array(allowFromEntry).optional(),
|
dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(),
|
||||||
groupAllowFrom: z.array(allowFromEntry).optional(),
|
allowFrom: z.array(allowFromEntry).optional(),
|
||||||
groupPolicy: z.enum(["open", "disabled", "allowlist"]).optional(),
|
groupAllowFrom: z.array(allowFromEntry).optional(),
|
||||||
historyLimit: z.number().int().min(0).optional(),
|
groupPolicy: z.enum(["open", "disabled", "allowlist"]).optional(),
|
||||||
dmHistoryLimit: z.number().int().min(0).optional(),
|
historyLimit: z.number().int().min(0).optional(),
|
||||||
textChunkLimit: z.number().int().positive().optional(),
|
dmHistoryLimit: z.number().int().min(0).optional(),
|
||||||
chunkMode: z.enum(["length", "newline"]).optional(),
|
textChunkLimit: z.number().int().positive().optional(),
|
||||||
mediaMaxMb: z.number().int().positive().optional(),
|
chunkMode: z.enum(["length", "newline"]).optional(),
|
||||||
mediaLocalRoots: z.array(z.string()).optional(),
|
mediaMaxMb: z.number().int().positive().optional(),
|
||||||
sendReadReceipts: z.boolean().optional(),
|
mediaLocalRoots: z.array(z.string()).optional(),
|
||||||
blockStreaming: z.boolean().optional(),
|
sendReadReceipts: z.boolean().optional(),
|
||||||
groups: z.object({}).catchall(bluebubblesGroupConfigSchema).optional(),
|
blockStreaming: z.boolean().optional(),
|
||||||
});
|
groups: z.object({}).catchall(bluebubblesGroupConfigSchema).optional(),
|
||||||
|
})
|
||||||
|
.superRefine((value, ctx) => {
|
||||||
|
const serverUrl = value.serverUrl?.trim() ?? "";
|
||||||
|
const password = value.password?.trim() ?? "";
|
||||||
|
if (serverUrl && !password) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["password"],
|
||||||
|
message: "password is required when serverUrl is configured",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export const BlueBubblesConfigSchema = bluebubblesAccountSchema.extend({
|
export const BlueBubblesConfigSchema = bluebubblesAccountSchema.extend({
|
||||||
accounts: z.object({}).catchall(bluebubblesAccountSchema).optional(),
|
accounts: z.object({}).catchall(bluebubblesAccountSchema).optional(),
|
||||||
|
|||||||
@@ -452,6 +452,45 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
expect(res.statusCode).toBe(400);
|
expect(res.statusCode).toBe(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("accepts URL-encoded payload wrappers", async () => {
|
||||||
|
const account = createMockAccount();
|
||||||
|
const config: OpenClawConfig = {};
|
||||||
|
const core = createMockRuntime();
|
||||||
|
setBlueBubblesRuntime(core);
|
||||||
|
|
||||||
|
unregister = registerBlueBubblesWebhookTarget({
|
||||||
|
account,
|
||||||
|
config,
|
||||||
|
runtime: { log: vi.fn(), error: vi.fn() },
|
||||||
|
core,
|
||||||
|
path: "/bluebubbles-webhook",
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
type: "new-message",
|
||||||
|
data: {
|
||||||
|
text: "hello",
|
||||||
|
handle: { address: "+15551234567" },
|
||||||
|
isGroup: false,
|
||||||
|
isFromMe: false,
|
||||||
|
guid: "msg-1",
|
||||||
|
date: Date.now(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const encodedBody = new URLSearchParams({
|
||||||
|
payload: JSON.stringify(payload),
|
||||||
|
}).toString();
|
||||||
|
|
||||||
|
const req = createMockRequest("POST", "/bluebubbles-webhook", encodedBody);
|
||||||
|
const res = createMockResponse();
|
||||||
|
|
||||||
|
const handled = await handleBlueBubblesWebhookRequest(req, res);
|
||||||
|
|
||||||
|
expect(handled).toBe(true);
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.body).toBe("ok");
|
||||||
|
});
|
||||||
|
|
||||||
it("returns 408 when request body times out (Slow-Loris protection)", async () => {
|
it("returns 408 when request body times out (Slow-Loris protection)", async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -2,8 +2,12 @@ import { timingSafeEqual } from "node:crypto";
|
|||||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||||
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
||||||
import {
|
import {
|
||||||
|
isRequestBodyLimitError,
|
||||||
|
readRequestBodyWithLimit,
|
||||||
registerWebhookTarget,
|
registerWebhookTarget,
|
||||||
rejectNonPostWebhookRequest,
|
rejectNonPostWebhookRequest,
|
||||||
|
requestBodyErrorToText,
|
||||||
|
resolveSingleWebhookTarget,
|
||||||
resolveWebhookTargets,
|
resolveWebhookTargets,
|
||||||
} from "openclaw/plugin-sdk";
|
} from "openclaw/plugin-sdk";
|
||||||
import {
|
import {
|
||||||
@@ -231,12 +235,6 @@ function removeDebouncer(target: WebhookTarget): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function registerBlueBubblesWebhookTarget(target: WebhookTarget): () => void {
|
export function registerBlueBubblesWebhookTarget(target: WebhookTarget): () => void {
|
||||||
const webhookPassword = target.account.config.password?.trim() ?? "";
|
|
||||||
if (!webhookPassword) {
|
|
||||||
target.runtime.error?.(
|
|
||||||
`[${target.account.accountId}] BlueBubbles webhook auth requires channels.bluebubbles.password. Configure a password and include it in the webhook URL.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const registered = registerWebhookTarget(webhookTargets, target);
|
const registered = registerWebhookTarget(webhookTargets, target);
|
||||||
return () => {
|
return () => {
|
||||||
registered.unregister();
|
registered.unregister();
|
||||||
@@ -245,64 +243,61 @@ export function registerBlueBubblesWebhookTarget(target: WebhookTarget): () => v
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readJsonBody(req: IncomingMessage, maxBytes: number, timeoutMs = 30_000) {
|
type ReadBlueBubblesWebhookBodyResult =
|
||||||
const chunks: Buffer[] = [];
|
| { ok: true; value: unknown }
|
||||||
let total = 0;
|
| { ok: false; statusCode: number; error: string };
|
||||||
return await new Promise<{ ok: boolean; value?: unknown; error?: string }>((resolve) => {
|
|
||||||
let done = false;
|
function parseBlueBubblesWebhookPayload(
|
||||||
const finish = (result: { ok: boolean; value?: unknown; error?: string }) => {
|
rawBody: string,
|
||||||
if (done) {
|
): { ok: true; value: unknown } | { ok: false; error: string } {
|
||||||
return;
|
const trimmed = rawBody.trim();
|
||||||
}
|
if (!trimmed) {
|
||||||
done = true;
|
return { ok: false, error: "empty payload" };
|
||||||
clearTimeout(timer);
|
}
|
||||||
resolve(result);
|
try {
|
||||||
|
return { ok: true, value: JSON.parse(trimmed) as unknown };
|
||||||
|
} catch {
|
||||||
|
const params = new URLSearchParams(rawBody);
|
||||||
|
const payload = params.get("payload") ?? params.get("data") ?? params.get("message");
|
||||||
|
if (!payload) {
|
||||||
|
return { ok: false, error: "invalid json" };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return { ok: true, value: JSON.parse(payload) as unknown };
|
||||||
|
} catch (error) {
|
||||||
|
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readBlueBubblesWebhookBody(
|
||||||
|
req: IncomingMessage,
|
||||||
|
maxBytes: number,
|
||||||
|
): Promise<ReadBlueBubblesWebhookBodyResult> {
|
||||||
|
try {
|
||||||
|
const rawBody = await readRequestBodyWithLimit(req, {
|
||||||
|
maxBytes,
|
||||||
|
timeoutMs: 30_000,
|
||||||
|
});
|
||||||
|
const parsed = parseBlueBubblesWebhookPayload(rawBody);
|
||||||
|
if (!parsed.ok) {
|
||||||
|
return { ok: false, statusCode: 400, error: parsed.error };
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
} catch (error) {
|
||||||
|
if (isRequestBodyLimitError(error)) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
statusCode: error.statusCode,
|
||||||
|
error: requestBodyErrorToText(error.code),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
statusCode: 400,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
};
|
};
|
||||||
|
}
|
||||||
const timer = setTimeout(() => {
|
|
||||||
finish({ ok: false, error: "request body timeout" });
|
|
||||||
req.destroy();
|
|
||||||
}, timeoutMs);
|
|
||||||
|
|
||||||
req.on("data", (chunk: Buffer) => {
|
|
||||||
total += chunk.length;
|
|
||||||
if (total > maxBytes) {
|
|
||||||
finish({ ok: false, error: "payload too large" });
|
|
||||||
req.destroy();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
chunks.push(chunk);
|
|
||||||
});
|
|
||||||
req.on("end", () => {
|
|
||||||
try {
|
|
||||||
const raw = Buffer.concat(chunks).toString("utf8");
|
|
||||||
if (!raw.trim()) {
|
|
||||||
finish({ ok: false, error: "empty payload" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
finish({ ok: true, value: JSON.parse(raw) as unknown });
|
|
||||||
return;
|
|
||||||
} catch {
|
|
||||||
const params = new URLSearchParams(raw);
|
|
||||||
const payload = params.get("payload") ?? params.get("data") ?? params.get("message");
|
|
||||||
if (payload) {
|
|
||||||
finish({ ok: true, value: JSON.parse(payload) as unknown });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
throw new Error("invalid json");
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
finish({ ok: false, error: err instanceof Error ? err.message : String(err) });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
req.on("error", (err) => {
|
|
||||||
finish({ ok: false, error: err instanceof Error ? err.message : String(err) });
|
|
||||||
});
|
|
||||||
req.on("close", () => {
|
|
||||||
finish({ ok: false, error: "connection closed" });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||||
@@ -343,26 +338,6 @@ function safeEqualSecret(aRaw: string, bRaw: string): boolean {
|
|||||||
return timingSafeEqual(bufA, bufB);
|
return timingSafeEqual(bufA, bufB);
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveAuthenticatedWebhookTargets(
|
|
||||||
targets: WebhookTarget[],
|
|
||||||
presentedToken: string,
|
|
||||||
): WebhookTarget[] {
|
|
||||||
const matches: WebhookTarget[] = [];
|
|
||||||
for (const target of targets) {
|
|
||||||
const token = target.account.config.password?.trim() ?? "";
|
|
||||||
if (!token) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (safeEqualSecret(presentedToken, token)) {
|
|
||||||
matches.push(target);
|
|
||||||
if (matches.length > 1) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return matches;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function handleBlueBubblesWebhookRequest(
|
export async function handleBlueBubblesWebhookRequest(
|
||||||
req: IncomingMessage,
|
req: IncomingMessage,
|
||||||
res: ServerResponse,
|
res: ServerResponse,
|
||||||
@@ -378,15 +353,9 @@ export async function handleBlueBubblesWebhookRequest(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = await readJsonBody(req, 1024 * 1024);
|
const body = await readBlueBubblesWebhookBody(req, 1024 * 1024);
|
||||||
if (!body.ok) {
|
if (!body.ok) {
|
||||||
if (body.error === "payload too large") {
|
res.statusCode = body.statusCode;
|
||||||
res.statusCode = 413;
|
|
||||||
} else if (body.error === "request body timeout") {
|
|
||||||
res.statusCode = 408;
|
|
||||||
} else {
|
|
||||||
res.statusCode = 400;
|
|
||||||
}
|
|
||||||
res.end(body.error ?? "invalid payload");
|
res.end(body.error ?? "invalid payload");
|
||||||
console.warn(`[bluebubbles] webhook rejected: ${body.error ?? "invalid payload"}`);
|
console.warn(`[bluebubbles] webhook rejected: ${body.error ?? "invalid payload"}`);
|
||||||
return true;
|
return true;
|
||||||
@@ -450,9 +419,12 @@ export async function handleBlueBubblesWebhookRequest(
|
|||||||
req.headers["x-bluebubbles-guid"] ??
|
req.headers["x-bluebubbles-guid"] ??
|
||||||
req.headers["authorization"];
|
req.headers["authorization"];
|
||||||
const guid = (Array.isArray(headerToken) ? headerToken[0] : headerToken) ?? guidParam ?? "";
|
const guid = (Array.isArray(headerToken) ? headerToken[0] : headerToken) ?? guidParam ?? "";
|
||||||
const matching = resolveAuthenticatedWebhookTargets(targets, guid);
|
const matchedTarget = resolveSingleWebhookTarget(targets, (target) => {
|
||||||
|
const token = target.account.config.password?.trim() ?? "";
|
||||||
|
return safeEqualSecret(guid, token);
|
||||||
|
});
|
||||||
|
|
||||||
if (matching.length === 0) {
|
if (matchedTarget.kind === "none") {
|
||||||
res.statusCode = 401;
|
res.statusCode = 401;
|
||||||
res.end("unauthorized");
|
res.end("unauthorized");
|
||||||
console.warn(
|
console.warn(
|
||||||
@@ -461,14 +433,14 @@ export async function handleBlueBubblesWebhookRequest(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (matching.length > 1) {
|
if (matchedTarget.kind === "ambiguous") {
|
||||||
res.statusCode = 401;
|
res.statusCode = 401;
|
||||||
res.end("ambiguous webhook target");
|
res.end("ambiguous webhook target");
|
||||||
console.warn(`[bluebubbles] webhook rejected: ambiguous target match path=${path}`);
|
console.warn(`[bluebubbles] webhook rejected: ambiguous target match path=${path}`);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const target = matching[0];
|
const target = matchedTarget.target;
|
||||||
target.statusSink?.({ lastInboundAt: Date.now() });
|
target.statusSink?.({ lastInboundAt: Date.now() });
|
||||||
if (reaction) {
|
if (reaction) {
|
||||||
processReaction(reaction, target).catch((err) => {
|
processReaction(reaction, target).catch((err) => {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
readJsonBodyWithLimit,
|
readJsonBodyWithLimit,
|
||||||
registerWebhookTarget,
|
registerWebhookTarget,
|
||||||
rejectNonPostWebhookRequest,
|
rejectNonPostWebhookRequest,
|
||||||
|
resolveSingleWebhookTargetAsync,
|
||||||
resolveWebhookPath,
|
resolveWebhookPath,
|
||||||
resolveWebhookTargets,
|
resolveWebhookTargets,
|
||||||
requestBodyErrorToText,
|
requestBodyErrorToText,
|
||||||
@@ -208,8 +209,7 @@ export async function handleGoogleChatWebhookRequest(
|
|||||||
? authHeaderNow.slice("bearer ".length)
|
? authHeaderNow.slice("bearer ".length)
|
||||||
: bearer;
|
: bearer;
|
||||||
|
|
||||||
const matchedTargets: WebhookTarget[] = [];
|
const matchedTarget = await resolveSingleWebhookTargetAsync(targets, async (target) => {
|
||||||
for (const target of targets) {
|
|
||||||
const audienceType = target.audienceType;
|
const audienceType = target.audienceType;
|
||||||
const audience = target.audience;
|
const audience = target.audience;
|
||||||
const verification = await verifyGoogleChatRequest({
|
const verification = await verifyGoogleChatRequest({
|
||||||
@@ -217,27 +217,22 @@ export async function handleGoogleChatWebhookRequest(
|
|||||||
audienceType,
|
audienceType,
|
||||||
audience,
|
audience,
|
||||||
});
|
});
|
||||||
if (verification.ok) {
|
return verification.ok;
|
||||||
matchedTargets.push(target);
|
});
|
||||||
if (matchedTargets.length > 1) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (matchedTargets.length === 0) {
|
if (matchedTarget.kind === "none") {
|
||||||
res.statusCode = 401;
|
res.statusCode = 401;
|
||||||
res.end("unauthorized");
|
res.end("unauthorized");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (matchedTargets.length > 1) {
|
if (matchedTarget.kind === "ambiguous") {
|
||||||
res.statusCode = 401;
|
res.statusCode = 401;
|
||||||
res.end("ambiguous webhook target");
|
res.end("ambiguous webhook target");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const selected = matchedTargets[0];
|
const selected = matchedTarget.target;
|
||||||
selected.statusSink?.({ lastInboundAt: Date.now() });
|
selected.statusSink?.({ lastInboundAt: Date.now() });
|
||||||
processGoogleChatEvent(event, selected).catch((err) => {
|
processGoogleChatEvent(event, selected).catch((err) => {
|
||||||
selected?.runtime.error?.(
|
selected?.runtime.error?.(
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
readJsonBodyWithLimit,
|
readJsonBodyWithLimit,
|
||||||
registerWebhookTarget,
|
registerWebhookTarget,
|
||||||
rejectNonPostWebhookRequest,
|
rejectNonPostWebhookRequest,
|
||||||
|
resolveSingleWebhookTarget,
|
||||||
resolveSenderCommandAuthorization,
|
resolveSenderCommandAuthorization,
|
||||||
resolveWebhookPath,
|
resolveWebhookPath,
|
||||||
resolveWebhookTargets,
|
resolveWebhookTargets,
|
||||||
@@ -195,20 +196,22 @@ export async function handleZaloWebhookRequest(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const headerToken = String(req.headers["x-bot-api-secret-token"] ?? "");
|
const headerToken = String(req.headers["x-bot-api-secret-token"] ?? "");
|
||||||
const matching = targets.filter((entry) => timingSafeEquals(entry.secret, headerToken));
|
const matchedTarget = resolveSingleWebhookTarget(targets, (entry) =>
|
||||||
if (matching.length === 0) {
|
timingSafeEquals(entry.secret, headerToken),
|
||||||
|
);
|
||||||
|
if (matchedTarget.kind === "none") {
|
||||||
res.statusCode = 401;
|
res.statusCode = 401;
|
||||||
res.end("unauthorized");
|
res.end("unauthorized");
|
||||||
recordWebhookStatus(targets[0]?.runtime, req.url ?? "<unknown>", res.statusCode);
|
recordWebhookStatus(targets[0]?.runtime, req.url ?? "<unknown>", res.statusCode);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (matching.length > 1) {
|
if (matchedTarget.kind === "ambiguous") {
|
||||||
res.statusCode = 401;
|
res.statusCode = 401;
|
||||||
res.end("ambiguous webhook target");
|
res.end("ambiguous webhook target");
|
||||||
recordWebhookStatus(targets[0]?.runtime, req.url ?? "<unknown>", res.statusCode);
|
recordWebhookStatus(targets[0]?.runtime, req.url ?? "<unknown>", res.statusCode);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
const target = matching[0];
|
const target = matchedTarget.target;
|
||||||
const path = req.url ?? "<unknown>";
|
const path = req.url ?? "<unknown>";
|
||||||
const rateLimitKey = `${path}:${req.socket.remoteAddress ?? "unknown"}`;
|
const rateLimitKey = `${path}:${req.socket.remoteAddress ?? "unknown"}`;
|
||||||
const nowMs = Date.now();
|
const nowMs = Date.now();
|
||||||
|
|||||||
@@ -88,8 +88,11 @@ export { normalizeWebhookPath, resolveWebhookPath } from "./webhook-path.js";
|
|||||||
export {
|
export {
|
||||||
registerWebhookTarget,
|
registerWebhookTarget,
|
||||||
rejectNonPostWebhookRequest,
|
rejectNonPostWebhookRequest,
|
||||||
|
resolveSingleWebhookTarget,
|
||||||
|
resolveSingleWebhookTargetAsync,
|
||||||
resolveWebhookTargets,
|
resolveWebhookTargets,
|
||||||
} from "./webhook-targets.js";
|
} from "./webhook-targets.js";
|
||||||
|
export type { WebhookTargetMatchResult } from "./webhook-targets.js";
|
||||||
export type { AgentMediaPayload } from "./agent-media-payload.js";
|
export type { AgentMediaPayload } from "./agent-media-payload.js";
|
||||||
export { buildAgentMediaPayload } from "./agent-media-payload.js";
|
export { buildAgentMediaPayload } from "./agent-media-payload.js";
|
||||||
export {
|
export {
|
||||||
|
|||||||
120
src/plugin-sdk/webhook-targets.test.ts
Normal file
120
src/plugin-sdk/webhook-targets.test.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import { EventEmitter } from "node:events";
|
||||||
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
registerWebhookTarget,
|
||||||
|
rejectNonPostWebhookRequest,
|
||||||
|
resolveSingleWebhookTarget,
|
||||||
|
resolveSingleWebhookTargetAsync,
|
||||||
|
resolveWebhookTargets,
|
||||||
|
} from "./webhook-targets.js";
|
||||||
|
|
||||||
|
function createRequest(method: string, url: string): IncomingMessage {
|
||||||
|
const req = new EventEmitter() as IncomingMessage;
|
||||||
|
req.method = method;
|
||||||
|
req.url = url;
|
||||||
|
req.headers = {};
|
||||||
|
return req;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("registerWebhookTarget", () => {
|
||||||
|
it("normalizes the path and unregisters cleanly", () => {
|
||||||
|
const targets = new Map<string, Array<{ path: string; id: string }>>();
|
||||||
|
const registered = registerWebhookTarget(targets, {
|
||||||
|
path: "hook",
|
||||||
|
id: "A",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(registered.target.path).toBe("/hook");
|
||||||
|
expect(targets.get("/hook")).toEqual([registered.target]);
|
||||||
|
|
||||||
|
registered.unregister();
|
||||||
|
expect(targets.has("/hook")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveWebhookTargets", () => {
|
||||||
|
it("resolves normalized path targets", () => {
|
||||||
|
const targets = new Map<string, Array<{ id: string }>>();
|
||||||
|
targets.set("/hook", [{ id: "A" }]);
|
||||||
|
|
||||||
|
expect(resolveWebhookTargets(createRequest("POST", "/hook/"), targets)).toEqual({
|
||||||
|
path: "/hook",
|
||||||
|
targets: [{ id: "A" }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when path has no targets", () => {
|
||||||
|
const targets = new Map<string, Array<{ id: string }>>();
|
||||||
|
expect(resolveWebhookTargets(createRequest("POST", "/missing"), targets)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("rejectNonPostWebhookRequest", () => {
|
||||||
|
it("sets 405 for non-POST requests", () => {
|
||||||
|
const setHeaderMock = vi.fn();
|
||||||
|
const endMock = vi.fn();
|
||||||
|
const res = {
|
||||||
|
statusCode: 200,
|
||||||
|
setHeader: setHeaderMock,
|
||||||
|
end: endMock,
|
||||||
|
} as unknown as ServerResponse;
|
||||||
|
|
||||||
|
const rejected = rejectNonPostWebhookRequest(createRequest("GET", "/hook"), res);
|
||||||
|
|
||||||
|
expect(rejected).toBe(true);
|
||||||
|
expect(res.statusCode).toBe(405);
|
||||||
|
expect(setHeaderMock).toHaveBeenCalledWith("Allow", "POST");
|
||||||
|
expect(endMock).toHaveBeenCalledWith("Method Not Allowed");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveSingleWebhookTarget", () => {
|
||||||
|
it("returns none when no target matches", () => {
|
||||||
|
const result = resolveSingleWebhookTarget(["a", "b"], (value) => value === "c");
|
||||||
|
expect(result).toEqual({ kind: "none" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the single match", () => {
|
||||||
|
const result = resolveSingleWebhookTarget(["a", "b"], (value) => value === "b");
|
||||||
|
expect(result).toEqual({ kind: "single", target: "b" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns ambiguous after second match", () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const result = resolveSingleWebhookTarget(["a", "b", "c"], (value) => {
|
||||||
|
calls.push(value);
|
||||||
|
return value === "a" || value === "b";
|
||||||
|
});
|
||||||
|
expect(result).toEqual({ kind: "ambiguous" });
|
||||||
|
expect(calls).toEqual(["a", "b"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveSingleWebhookTargetAsync", () => {
|
||||||
|
it("returns none when no target matches", async () => {
|
||||||
|
const result = await resolveSingleWebhookTargetAsync(
|
||||||
|
["a", "b"],
|
||||||
|
async (value) => value === "c",
|
||||||
|
);
|
||||||
|
expect(result).toEqual({ kind: "none" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the single async match", async () => {
|
||||||
|
const result = await resolveSingleWebhookTargetAsync(
|
||||||
|
["a", "b"],
|
||||||
|
async (value) => value === "b",
|
||||||
|
);
|
||||||
|
expect(result).toEqual({ kind: "single", target: "b" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns ambiguous after second async match", async () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const result = await resolveSingleWebhookTargetAsync(["a", "b", "c"], async (value) => {
|
||||||
|
calls.push(value);
|
||||||
|
return value === "a" || value === "b";
|
||||||
|
});
|
||||||
|
expect(result).toEqual({ kind: "ambiguous" });
|
||||||
|
expect(calls).toEqual(["a", "b"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -38,6 +38,51 @@ export function resolveWebhookTargets<T>(
|
|||||||
return { path, targets };
|
return { path, targets };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type WebhookTargetMatchResult<T> =
|
||||||
|
| { kind: "none" }
|
||||||
|
| { kind: "single"; target: T }
|
||||||
|
| { kind: "ambiguous" };
|
||||||
|
|
||||||
|
export function resolveSingleWebhookTarget<T>(
|
||||||
|
targets: readonly T[],
|
||||||
|
isMatch: (target: T) => boolean,
|
||||||
|
): WebhookTargetMatchResult<T> {
|
||||||
|
let matched: T | undefined;
|
||||||
|
for (const target of targets) {
|
||||||
|
if (!isMatch(target)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (matched) {
|
||||||
|
return { kind: "ambiguous" };
|
||||||
|
}
|
||||||
|
matched = target;
|
||||||
|
}
|
||||||
|
if (!matched) {
|
||||||
|
return { kind: "none" };
|
||||||
|
}
|
||||||
|
return { kind: "single", target: matched };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveSingleWebhookTargetAsync<T>(
|
||||||
|
targets: readonly T[],
|
||||||
|
isMatch: (target: T) => Promise<boolean>,
|
||||||
|
): Promise<WebhookTargetMatchResult<T>> {
|
||||||
|
let matched: T | undefined;
|
||||||
|
for (const target of targets) {
|
||||||
|
if (!(await isMatch(target))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (matched) {
|
||||||
|
return { kind: "ambiguous" };
|
||||||
|
}
|
||||||
|
matched = target;
|
||||||
|
}
|
||||||
|
if (!matched) {
|
||||||
|
return { kind: "none" };
|
||||||
|
}
|
||||||
|
return { kind: "single", target: matched };
|
||||||
|
}
|
||||||
|
|
||||||
export function rejectNonPostWebhookRequest(req: IncomingMessage, res: ServerResponse): boolean {
|
export function rejectNonPostWebhookRequest(req: IncomingMessage, res: ServerResponse): boolean {
|
||||||
if (req.method === "POST") {
|
if (req.method === "POST") {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
Reference in New Issue
Block a user