mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
fix: harden webhook auth-before-body handling
This commit is contained in:
@@ -49,6 +49,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Security/Node exec approvals: preserve shell/dispatch-wrapper argv semantics during approval hardening so approved wrapper commands (for example `env sh -c ...`) cannot drift into a different runtime command shape, and add regression coverage for both approval-plan generation and approved runtime execution paths. Thanks @tdjackey for reporting.
|
||||
- Sandbox/Bootstrap context boundary hardening: reject symlink/hardlink alias bootstrap seed files that resolve outside the source workspace and switch post-compaction `AGENTS.md` context reads to boundary-verified file opens, preventing host file content from being injected via workspace aliasing. Thanks @tdjackey for reporting.
|
||||
- Browser/Security output boundary hardening: replace check-then-rename output commits with root-bound fd-verified writes, unify install/skills canonical path-boundary checks, and add regression coverage for symlink-rebind race paths across browser output and shared fs-safe write flows. Thanks @tdjackey for reporting.
|
||||
- Security/Webhook request hardening: enforce auth-before-body parsing for BlueBubbles and Google Chat webhook handlers, add strict pre-auth body/time budgets for webhook auth paths (including LINE signature verification), and add shared in-flight/request guardrails plus regression tests/lint checks to prevent reintroducing unauthenticated slow-body DoS patterns. Thanks @GCXWLP for reporting.
|
||||
- Gateway/Security hardening: tie loopback-origin dev allowance to actual local socket clients (not Host header claims), add explicit warnings/metrics when `gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback` accepts websocket origins, harden safe-regex detection for quantified ambiguous alternation patterns (for example `(a|aa)+`), and bound large regex-evaluation inputs for session-filter and log-redaction paths.
|
||||
- Tests/Sandbox + archive portability: use junction-compatible directory-link setup on Windows and explicit file-symlink platform guards in symlink escape tests where unprivileged file symlinks are unavailable, reducing false Windows CI failures while preserving traversal checks on supported paths. (#28747) Thanks @arosstale.
|
||||
- Security/Skills archive extraction: unify tar extraction safety checks across tar.gz and tar.bz2 install flows, enforce tar compressed-size limits, and fail closed if tar.bz2 archives change between preflight and extraction to prevent bypasses of entry-type/size guardrails. Thanks @GCXWLP for reporting.
|
||||
|
||||
@@ -48,6 +48,7 @@ Security note:
|
||||
|
||||
- Always set a webhook password.
|
||||
- Webhook authentication is always required. OpenClaw rejects BlueBubbles webhook requests unless they include a password/guid that matches `channels.bluebubbles.password` (for example `?password=<password>` or `x-password`), regardless of loopback/proxy topology.
|
||||
- Password authentication is checked before reading/parsing full webhook bodies.
|
||||
|
||||
## Keeping Messages.app alive (VM / headless setups)
|
||||
|
||||
|
||||
@@ -139,6 +139,8 @@ Configure your tunnel's ingress rules to only route the webhook path:
|
||||
## How it works
|
||||
|
||||
1. Google Chat sends webhook POSTs to the gateway. Each request includes an `Authorization: Bearer <token>` header.
|
||||
- OpenClaw verifies bearer auth before reading/parsing full webhook bodies when the header is present.
|
||||
- Google Workspace Add-on requests that carry `authorizationEventObject.systemIdToken` in the body are supported via a stricter pre-auth body budget.
|
||||
2. OpenClaw verifies the token against the configured `audienceType` + `audience`:
|
||||
- `audienceType: "app-url"` → audience is your HTTPS webhook URL.
|
||||
- `audienceType: "project-number"` → audience is the Cloud project number.
|
||||
|
||||
@@ -48,6 +48,10 @@ The gateway responds to LINE’s webhook verification (GET) and inbound events (
|
||||
If you need a custom path, set `channels.line.webhookPath` or
|
||||
`channels.line.accounts.<id>.webhookPath` and update the URL accordingly.
|
||||
|
||||
Security note:
|
||||
|
||||
- LINE signature verification is body-dependent (HMAC over the raw body), so OpenClaw applies strict pre-auth body limits and timeout before verification.
|
||||
|
||||
## Configure
|
||||
|
||||
Minimal config:
|
||||
|
||||
@@ -535,7 +535,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
// Create a request that never sends data or ends (simulates slow-loris)
|
||||
const req = new EventEmitter() as IncomingMessage;
|
||||
req.method = "POST";
|
||||
req.url = "/bluebubbles-webhook";
|
||||
req.url = "/bluebubbles-webhook?password=test-password";
|
||||
req.headers = {};
|
||||
(req as unknown as { socket: { remoteAddress: string } }).socket = {
|
||||
remoteAddress: "127.0.0.1",
|
||||
@@ -558,6 +558,37 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects unauthorized requests before reading the body", async () => {
|
||||
const account = createMockAccount({ password: "secret-token" });
|
||||
const config: OpenClawConfig = {};
|
||||
const core = createMockRuntime();
|
||||
setBlueBubblesRuntime(core);
|
||||
|
||||
unregister = registerBlueBubblesWebhookTarget({
|
||||
account,
|
||||
config,
|
||||
runtime: { log: vi.fn(), error: vi.fn() },
|
||||
core,
|
||||
path: "/bluebubbles-webhook",
|
||||
});
|
||||
|
||||
const req = new EventEmitter() as IncomingMessage;
|
||||
req.method = "POST";
|
||||
req.url = "/bluebubbles-webhook?password=wrong-token";
|
||||
req.headers = {};
|
||||
const onSpy = vi.spyOn(req, "on");
|
||||
(req as unknown as { socket: { remoteAddress: string } }).socket = {
|
||||
remoteAddress: "127.0.0.1",
|
||||
};
|
||||
|
||||
const res = createMockResponse();
|
||||
const handled = await handleBlueBubblesWebhookRequest(req, res);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(onSpy).not.toHaveBeenCalledWith("data", expect.any(Function));
|
||||
});
|
||||
|
||||
it("authenticates via password query parameter", async () => {
|
||||
const account = createMockAccount({ password: "secret-token" });
|
||||
const config: OpenClawConfig = {};
|
||||
|
||||
@@ -2,11 +2,10 @@ import { timingSafeEqual } from "node:crypto";
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
||||
import {
|
||||
isRequestBodyLimitError,
|
||||
readRequestBodyWithLimit,
|
||||
beginWebhookRequestPipelineOrReject,
|
||||
createWebhookInFlightLimiter,
|
||||
registerWebhookTargetWithPluginRoute,
|
||||
rejectNonPostWebhookRequest,
|
||||
requestBodyErrorToText,
|
||||
readWebhookBodyOrReject,
|
||||
resolveSingleWebhookTarget,
|
||||
resolveWebhookTargets,
|
||||
} from "openclaw/plugin-sdk";
|
||||
@@ -114,6 +113,7 @@ function combineDebounceEntries(entries: BlueBubblesDebounceEntry[]): Normalized
|
||||
}
|
||||
|
||||
const webhookTargets = new Map<string, WebhookTarget[]>();
|
||||
const webhookInFlightLimiter = createWebhookInFlightLimiter();
|
||||
|
||||
type BlueBubblesDebouncer = {
|
||||
enqueue: (item: BlueBubblesDebounceEntry) => Promise<void>;
|
||||
@@ -262,10 +262,6 @@ export function registerBlueBubblesWebhookTarget(target: WebhookTarget): () => v
|
||||
};
|
||||
}
|
||||
|
||||
type ReadBlueBubblesWebhookBodyResult =
|
||||
| { ok: true; value: unknown }
|
||||
| { ok: false; statusCode: number; error: string };
|
||||
|
||||
function parseBlueBubblesWebhookPayload(
|
||||
rawBody: string,
|
||||
): { ok: true; value: unknown } | { ok: false; error: string } {
|
||||
@@ -289,36 +285,6 @@ function parseBlueBubblesWebhookPayload(
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
@@ -367,137 +333,158 @@ export async function handleBlueBubblesWebhookRequest(
|
||||
}
|
||||
const { path, targets } = resolved;
|
||||
const url = new URL(req.url ?? "/", "http://localhost");
|
||||
|
||||
if (rejectNonPostWebhookRequest(req, res)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const body = await readBlueBubblesWebhookBody(req, 1024 * 1024);
|
||||
if (!body.ok) {
|
||||
res.statusCode = body.statusCode;
|
||||
res.end(body.error ?? "invalid payload");
|
||||
console.warn(`[bluebubbles] webhook rejected: ${body.error ?? "invalid payload"}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
const payload = asRecord(body.value) ?? {};
|
||||
const firstTarget = targets[0];
|
||||
if (firstTarget) {
|
||||
logVerbose(
|
||||
firstTarget.core,
|
||||
firstTarget.runtime,
|
||||
`webhook received path=${path} keys=${Object.keys(payload).join(",") || "none"}`,
|
||||
);
|
||||
}
|
||||
const eventTypeRaw = payload.type;
|
||||
const eventType = typeof eventTypeRaw === "string" ? eventTypeRaw.trim() : "";
|
||||
const allowedEventTypes = new Set([
|
||||
"new-message",
|
||||
"updated-message",
|
||||
"message-reaction",
|
||||
"reaction",
|
||||
]);
|
||||
if (eventType && !allowedEventTypes.has(eventType)) {
|
||||
res.statusCode = 200;
|
||||
res.end("ok");
|
||||
if (firstTarget) {
|
||||
logVerbose(firstTarget.core, firstTarget.runtime, `webhook ignored type=${eventType}`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
const reaction = normalizeWebhookReaction(payload);
|
||||
if (
|
||||
(eventType === "updated-message" ||
|
||||
eventType === "message-reaction" ||
|
||||
eventType === "reaction") &&
|
||||
!reaction
|
||||
) {
|
||||
res.statusCode = 200;
|
||||
res.end("ok");
|
||||
if (firstTarget) {
|
||||
logVerbose(
|
||||
firstTarget.core,
|
||||
firstTarget.runtime,
|
||||
`webhook ignored ${eventType || "event"} without reaction`,
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
const message = reaction ? null : normalizeWebhookMessage(payload);
|
||||
if (!message && !reaction) {
|
||||
res.statusCode = 400;
|
||||
res.end("invalid payload");
|
||||
console.warn("[bluebubbles] webhook rejected: unable to parse message payload");
|
||||
return true;
|
||||
}
|
||||
|
||||
const guidParam = url.searchParams.get("guid") ?? url.searchParams.get("password");
|
||||
const headerToken =
|
||||
req.headers["x-guid"] ??
|
||||
req.headers["x-password"] ??
|
||||
req.headers["x-bluebubbles-guid"] ??
|
||||
req.headers["authorization"];
|
||||
const guid = (Array.isArray(headerToken) ? headerToken[0] : headerToken) ?? guidParam ?? "";
|
||||
const matchedTarget = resolveSingleWebhookTarget(targets, (target) => {
|
||||
const token = target.account.config.password?.trim() ?? "";
|
||||
return safeEqualSecret(guid, token);
|
||||
const requestLifecycle = beginWebhookRequestPipelineOrReject({
|
||||
req,
|
||||
res,
|
||||
allowMethods: ["POST"],
|
||||
inFlightLimiter: webhookInFlightLimiter,
|
||||
inFlightKey: `${path}:${req.socket.remoteAddress ?? "unknown"}`,
|
||||
});
|
||||
|
||||
if (matchedTarget.kind === "none") {
|
||||
res.statusCode = 401;
|
||||
res.end("unauthorized");
|
||||
console.warn(
|
||||
`[bluebubbles] webhook rejected: unauthorized guid=${maskSecret(url.searchParams.get("guid") ?? url.searchParams.get("password") ?? "")}`,
|
||||
);
|
||||
if (!requestLifecycle.ok) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (matchedTarget.kind === "ambiguous") {
|
||||
res.statusCode = 401;
|
||||
res.end("ambiguous webhook target");
|
||||
console.warn(`[bluebubbles] webhook rejected: ambiguous target match path=${path}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
const target = matchedTarget.target;
|
||||
target.statusSink?.({ lastInboundAt: Date.now() });
|
||||
if (reaction) {
|
||||
processReaction(reaction, target).catch((err) => {
|
||||
target.runtime.error?.(
|
||||
`[${target.account.accountId}] BlueBubbles reaction failed: ${String(err)}`,
|
||||
);
|
||||
try {
|
||||
const guidParam = url.searchParams.get("guid") ?? url.searchParams.get("password");
|
||||
const headerToken =
|
||||
req.headers["x-guid"] ??
|
||||
req.headers["x-password"] ??
|
||||
req.headers["x-bluebubbles-guid"] ??
|
||||
req.headers["authorization"];
|
||||
const guid = (Array.isArray(headerToken) ? headerToken[0] : headerToken) ?? guidParam ?? "";
|
||||
const matchedTarget = resolveSingleWebhookTarget(targets, (target) => {
|
||||
const token = target.account.config.password?.trim() ?? "";
|
||||
return safeEqualSecret(guid, token);
|
||||
});
|
||||
} else if (message) {
|
||||
// Route messages through debouncer to coalesce rapid-fire events
|
||||
// (e.g., text message + URL balloon arriving as separate webhooks)
|
||||
const debouncer = getOrCreateDebouncer(target);
|
||||
debouncer.enqueue({ message, target }).catch((err) => {
|
||||
target.runtime.error?.(
|
||||
`[${target.account.accountId}] BlueBubbles webhook failed: ${String(err)}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
res.statusCode = 200;
|
||||
res.end("ok");
|
||||
if (reaction) {
|
||||
if (matchedTarget.kind === "none") {
|
||||
res.statusCode = 401;
|
||||
res.end("unauthorized");
|
||||
console.warn(
|
||||
`[bluebubbles] webhook rejected: unauthorized guid=${maskSecret(url.searchParams.get("guid") ?? url.searchParams.get("password") ?? "")}`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (matchedTarget.kind === "ambiguous") {
|
||||
res.statusCode = 401;
|
||||
res.end("ambiguous webhook target");
|
||||
console.warn(`[bluebubbles] webhook rejected: ambiguous target match path=${path}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
const target = matchedTarget.target;
|
||||
const body = await readWebhookBodyOrReject({
|
||||
req,
|
||||
res,
|
||||
profile: "post-auth",
|
||||
invalidBodyMessage: "invalid payload",
|
||||
});
|
||||
if (!body.ok) {
|
||||
console.warn(`[bluebubbles] webhook rejected: status=${res.statusCode}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
const parsed = parseBlueBubblesWebhookPayload(body.value);
|
||||
if (!parsed.ok) {
|
||||
res.statusCode = 400;
|
||||
res.end(parsed.error);
|
||||
console.warn(`[bluebubbles] webhook rejected: ${parsed.error}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
const payload = asRecord(parsed.value) ?? {};
|
||||
const firstTarget = targets[0];
|
||||
if (firstTarget) {
|
||||
logVerbose(
|
||||
firstTarget.core,
|
||||
firstTarget.runtime,
|
||||
`webhook accepted reaction sender=${reaction.senderId} msg=${reaction.messageId} action=${reaction.action}`,
|
||||
`webhook received path=${path} keys=${Object.keys(payload).join(",") || "none"}`,
|
||||
);
|
||||
}
|
||||
} else if (message) {
|
||||
if (firstTarget) {
|
||||
logVerbose(
|
||||
firstTarget.core,
|
||||
firstTarget.runtime,
|
||||
`webhook accepted sender=${message.senderId} group=${message.isGroup} chatGuid=${message.chatGuid ?? ""} chatId=${message.chatId ?? ""}`,
|
||||
);
|
||||
const eventTypeRaw = payload.type;
|
||||
const eventType = typeof eventTypeRaw === "string" ? eventTypeRaw.trim() : "";
|
||||
const allowedEventTypes = new Set([
|
||||
"new-message",
|
||||
"updated-message",
|
||||
"message-reaction",
|
||||
"reaction",
|
||||
]);
|
||||
if (eventType && !allowedEventTypes.has(eventType)) {
|
||||
res.statusCode = 200;
|
||||
res.end("ok");
|
||||
if (firstTarget) {
|
||||
logVerbose(firstTarget.core, firstTarget.runtime, `webhook ignored type=${eventType}`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
const reaction = normalizeWebhookReaction(payload);
|
||||
if (
|
||||
(eventType === "updated-message" ||
|
||||
eventType === "message-reaction" ||
|
||||
eventType === "reaction") &&
|
||||
!reaction
|
||||
) {
|
||||
res.statusCode = 200;
|
||||
res.end("ok");
|
||||
if (firstTarget) {
|
||||
logVerbose(
|
||||
firstTarget.core,
|
||||
firstTarget.runtime,
|
||||
`webhook ignored ${eventType || "event"} without reaction`,
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
const message = reaction ? null : normalizeWebhookMessage(payload);
|
||||
if (!message && !reaction) {
|
||||
res.statusCode = 400;
|
||||
res.end("invalid payload");
|
||||
console.warn("[bluebubbles] webhook rejected: unable to parse message payload");
|
||||
return true;
|
||||
}
|
||||
|
||||
target.statusSink?.({ lastInboundAt: Date.now() });
|
||||
if (reaction) {
|
||||
processReaction(reaction, target).catch((err) => {
|
||||
target.runtime.error?.(
|
||||
`[${target.account.accountId}] BlueBubbles reaction failed: ${String(err)}`,
|
||||
);
|
||||
});
|
||||
} else if (message) {
|
||||
// Route messages through debouncer to coalesce rapid-fire events
|
||||
// (e.g., text message + URL balloon arriving as separate webhooks)
|
||||
const debouncer = getOrCreateDebouncer(target);
|
||||
debouncer.enqueue({ message, target }).catch((err) => {
|
||||
target.runtime.error?.(
|
||||
`[${target.account.accountId}] BlueBubbles webhook failed: ${String(err)}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
res.statusCode = 200;
|
||||
res.end("ok");
|
||||
if (reaction) {
|
||||
if (firstTarget) {
|
||||
logVerbose(
|
||||
firstTarget.core,
|
||||
firstTarget.runtime,
|
||||
`webhook accepted reaction sender=${reaction.senderId} msg=${reaction.messageId} action=${reaction.action}`,
|
||||
);
|
||||
}
|
||||
} else if (message) {
|
||||
if (firstTarget) {
|
||||
logVerbose(
|
||||
firstTarget.core,
|
||||
firstTarget.runtime,
|
||||
`webhook accepted sender=${message.senderId} group=${message.isGroup} chatGuid=${message.chatGuid ?? ""} chatId=${message.chatId ?? ""}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} finally {
|
||||
requestLifecycle.release();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function monitorBlueBubblesProvider(
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
||||
import {
|
||||
beginWebhookRequestPipelineOrReject,
|
||||
createWebhookInFlightLimiter,
|
||||
GROUP_POLICY_BLOCKED_LABEL,
|
||||
createScopedPairingAccess,
|
||||
createReplyPrefixOptions,
|
||||
readJsonBodyWithLimit,
|
||||
readJsonWebhookBodyOrReject,
|
||||
registerWebhookTargetWithPluginRoute,
|
||||
rejectNonPostWebhookRequest,
|
||||
isDangerousNameMatchingEnabled,
|
||||
resolveAllowlistProviderRuntimeGroupPolicy,
|
||||
resolveDefaultGroupPolicy,
|
||||
@@ -15,7 +16,6 @@ import {
|
||||
resolveWebhookPath,
|
||||
resolveWebhookTargets,
|
||||
warnMissingProviderGroupPolicyFallbackOnce,
|
||||
requestBodyErrorToText,
|
||||
resolveMentionGatingWithBypass,
|
||||
resolveDmGroupAccessWithLists,
|
||||
} from "openclaw/plugin-sdk";
|
||||
@@ -67,6 +67,7 @@ type WebhookTarget = {
|
||||
};
|
||||
|
||||
const webhookTargets = new Map<string, WebhookTarget[]>();
|
||||
const webhookInFlightLimiter = createWebhookInFlightLimiter();
|
||||
|
||||
function logVerbose(core: GoogleChatCoreRuntime, runtime: GoogleChatRuntimeEnv, message: string) {
|
||||
if (core.logging.shouldLogVerbose()) {
|
||||
@@ -137,49 +138,31 @@ function normalizeAudienceType(value?: string | null): GoogleChatAudienceType |
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function handleGoogleChatWebhookRequest(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
): Promise<boolean> {
|
||||
const resolved = resolveWebhookTargets(req, webhookTargets);
|
||||
if (!resolved) {
|
||||
return false;
|
||||
}
|
||||
const { targets } = resolved;
|
||||
|
||||
if (rejectNonPostWebhookRequest(req, res)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const authHeader = String(req.headers.authorization ?? "");
|
||||
const bearer = authHeader.toLowerCase().startsWith("bearer ")
|
||||
? authHeader.slice("bearer ".length)
|
||||
function extractBearerToken(header: unknown): string {
|
||||
const authHeader = Array.isArray(header) ? String(header[0] ?? "") : String(header ?? "");
|
||||
return authHeader.toLowerCase().startsWith("bearer ")
|
||||
? authHeader.slice("bearer ".length).trim()
|
||||
: "";
|
||||
}
|
||||
|
||||
const body = await readJsonBodyWithLimit(req, {
|
||||
maxBytes: 1024 * 1024,
|
||||
timeoutMs: 30_000,
|
||||
emptyObjectOnEmpty: false,
|
||||
});
|
||||
if (!body.ok) {
|
||||
res.statusCode =
|
||||
body.code === "PAYLOAD_TOO_LARGE" ? 413 : body.code === "REQUEST_BODY_TIMEOUT" ? 408 : 400;
|
||||
res.end(
|
||||
body.code === "REQUEST_BODY_TIMEOUT"
|
||||
? requestBodyErrorToText("REQUEST_BODY_TIMEOUT")
|
||||
: body.error,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
type ParsedGoogleChatInboundPayload =
|
||||
| { ok: true; event: GoogleChatEvent; addOnBearerToken: string }
|
||||
| { ok: false };
|
||||
|
||||
let raw = body.value;
|
||||
function parseGoogleChatInboundPayload(
|
||||
raw: unknown,
|
||||
res: ServerResponse,
|
||||
): ParsedGoogleChatInboundPayload {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
||||
res.statusCode = 400;
|
||||
res.end("invalid payload");
|
||||
return true;
|
||||
return { ok: false };
|
||||
}
|
||||
|
||||
// Transform Google Workspace Add-on format to standard Chat API format
|
||||
let eventPayload = raw;
|
||||
let addOnBearerToken = "";
|
||||
|
||||
// Transform Google Workspace Add-on format to standard Chat API format.
|
||||
const rawObj = raw as {
|
||||
commonEventObject?: { hostApp?: string };
|
||||
chat?: {
|
||||
@@ -193,84 +176,173 @@ export async function handleGoogleChatWebhookRequest(
|
||||
if (rawObj.commonEventObject?.hostApp === "CHAT" && rawObj.chat?.messagePayload) {
|
||||
const chat = rawObj.chat;
|
||||
const messagePayload = chat.messagePayload;
|
||||
raw = {
|
||||
eventPayload = {
|
||||
type: "MESSAGE",
|
||||
space: messagePayload?.space,
|
||||
message: messagePayload?.message,
|
||||
user: chat.user,
|
||||
eventTime: chat.eventTime,
|
||||
};
|
||||
|
||||
// For Add-ons, the bearer token may be in authorizationEventObject.systemIdToken
|
||||
const systemIdToken = rawObj.authorizationEventObject?.systemIdToken;
|
||||
if (!bearer && systemIdToken) {
|
||||
Object.assign(req.headers, { authorization: `Bearer ${systemIdToken}` });
|
||||
}
|
||||
addOnBearerToken = String(rawObj.authorizationEventObject?.systemIdToken ?? "").trim();
|
||||
}
|
||||
|
||||
const event = raw as GoogleChatEvent;
|
||||
const eventType = event.type ?? (raw as { eventType?: string }).eventType;
|
||||
const event = eventPayload as GoogleChatEvent;
|
||||
const eventType = event.type ?? (eventPayload as { eventType?: string }).eventType;
|
||||
if (typeof eventType !== "string") {
|
||||
res.statusCode = 400;
|
||||
res.end("invalid payload");
|
||||
return true;
|
||||
return { ok: false };
|
||||
}
|
||||
|
||||
if (!event.space || typeof event.space !== "object" || Array.isArray(event.space)) {
|
||||
res.statusCode = 400;
|
||||
res.end("invalid payload");
|
||||
return true;
|
||||
return { ok: false };
|
||||
}
|
||||
|
||||
if (eventType === "MESSAGE") {
|
||||
if (!event.message || typeof event.message !== "object" || Array.isArray(event.message)) {
|
||||
res.statusCode = 400;
|
||||
res.end("invalid payload");
|
||||
return true;
|
||||
return { ok: false };
|
||||
}
|
||||
}
|
||||
|
||||
// Re-extract bearer in case it was updated from Add-on format
|
||||
const authHeaderNow = String(req.headers.authorization ?? "");
|
||||
const effectiveBearer = authHeaderNow.toLowerCase().startsWith("bearer ")
|
||||
? authHeaderNow.slice("bearer ".length)
|
||||
: bearer;
|
||||
return { ok: true, event, addOnBearerToken };
|
||||
}
|
||||
|
||||
const matchedTarget = await resolveSingleWebhookTargetAsync(targets, async (target) => {
|
||||
const audienceType = target.audienceType;
|
||||
const audience = target.audience;
|
||||
async function resolveGoogleChatWebhookTargetByBearer(
|
||||
targets: readonly WebhookTarget[],
|
||||
bearer: string,
|
||||
) {
|
||||
return await resolveSingleWebhookTargetAsync(targets, async (target) => {
|
||||
const verification = await verifyGoogleChatRequest({
|
||||
bearer: effectiveBearer,
|
||||
audienceType,
|
||||
audience,
|
||||
bearer,
|
||||
audienceType: target.audienceType,
|
||||
audience: target.audience,
|
||||
});
|
||||
return verification.ok;
|
||||
});
|
||||
}
|
||||
|
||||
if (matchedTarget.kind === "none") {
|
||||
res.statusCode = 401;
|
||||
res.end("unauthorized");
|
||||
return true;
|
||||
export async function handleGoogleChatWebhookRequest(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
): Promise<boolean> {
|
||||
const resolved = resolveWebhookTargets(req, webhookTargets);
|
||||
if (!resolved) {
|
||||
return false;
|
||||
}
|
||||
const { path, targets } = resolved;
|
||||
|
||||
if (matchedTarget.kind === "ambiguous") {
|
||||
res.statusCode = 401;
|
||||
res.end("ambiguous webhook target");
|
||||
return true;
|
||||
}
|
||||
|
||||
const selected = matchedTarget.target;
|
||||
selected.statusSink?.({ lastInboundAt: Date.now() });
|
||||
processGoogleChatEvent(event, selected).catch((err) => {
|
||||
selected?.runtime.error?.(
|
||||
`[${selected.account.accountId}] Google Chat webhook failed: ${String(err)}`,
|
||||
);
|
||||
const requestLifecycle = beginWebhookRequestPipelineOrReject({
|
||||
req,
|
||||
res,
|
||||
allowMethods: ["POST"],
|
||||
requireJsonContentType: true,
|
||||
inFlightLimiter: webhookInFlightLimiter,
|
||||
inFlightKey: `${path}:${req.socket?.remoteAddress ?? "unknown"}`,
|
||||
});
|
||||
if (!requestLifecycle.ok) {
|
||||
return true;
|
||||
}
|
||||
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end("{}");
|
||||
return true;
|
||||
try {
|
||||
const headerBearer = extractBearerToken(req.headers.authorization);
|
||||
let matchedTarget: Awaited<ReturnType<typeof resolveGoogleChatWebhookTargetByBearer>> | null =
|
||||
null;
|
||||
let parsedEvent: GoogleChatEvent | null = null;
|
||||
let addOnBearerToken = "";
|
||||
|
||||
if (headerBearer) {
|
||||
matchedTarget = await resolveGoogleChatWebhookTargetByBearer(targets, headerBearer);
|
||||
if (matchedTarget.kind === "none") {
|
||||
res.statusCode = 401;
|
||||
res.end("unauthorized");
|
||||
return true;
|
||||
}
|
||||
if (matchedTarget.kind === "ambiguous") {
|
||||
res.statusCode = 401;
|
||||
res.end("ambiguous webhook target");
|
||||
return true;
|
||||
}
|
||||
|
||||
const body = await readJsonWebhookBodyOrReject({
|
||||
req,
|
||||
res,
|
||||
profile: "post-auth",
|
||||
emptyObjectOnEmpty: false,
|
||||
invalidJsonMessage: "invalid payload",
|
||||
});
|
||||
if (!body.ok) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const parsed = parseGoogleChatInboundPayload(body.value, res);
|
||||
if (!parsed.ok) {
|
||||
return true;
|
||||
}
|
||||
parsedEvent = parsed.event;
|
||||
addOnBearerToken = parsed.addOnBearerToken;
|
||||
} else {
|
||||
const body = await readJsonWebhookBodyOrReject({
|
||||
req,
|
||||
res,
|
||||
profile: "pre-auth",
|
||||
emptyObjectOnEmpty: false,
|
||||
invalidJsonMessage: "invalid payload",
|
||||
});
|
||||
if (!body.ok) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const parsed = parseGoogleChatInboundPayload(body.value, res);
|
||||
if (!parsed.ok) {
|
||||
return true;
|
||||
}
|
||||
parsedEvent = parsed.event;
|
||||
addOnBearerToken = parsed.addOnBearerToken;
|
||||
|
||||
if (!addOnBearerToken) {
|
||||
res.statusCode = 401;
|
||||
res.end("unauthorized");
|
||||
return true;
|
||||
}
|
||||
|
||||
matchedTarget = await resolveGoogleChatWebhookTargetByBearer(targets, addOnBearerToken);
|
||||
if (matchedTarget.kind === "none") {
|
||||
res.statusCode = 401;
|
||||
res.end("unauthorized");
|
||||
return true;
|
||||
}
|
||||
if (matchedTarget.kind === "ambiguous") {
|
||||
res.statusCode = 401;
|
||||
res.end("ambiguous webhook target");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!matchedTarget || !parsedEvent) {
|
||||
res.statusCode = 401;
|
||||
res.end("unauthorized");
|
||||
return true;
|
||||
}
|
||||
|
||||
const selected = matchedTarget.target;
|
||||
selected.statusSink?.({ lastInboundAt: Date.now() });
|
||||
processGoogleChatEvent(parsedEvent, selected).catch((err) => {
|
||||
selected.runtime.error?.(
|
||||
`[${selected.account.accountId}] Google Chat webhook failed: ${String(err)}`,
|
||||
);
|
||||
});
|
||||
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end("{}");
|
||||
return true;
|
||||
} finally {
|
||||
requestLifecycle.release();
|
||||
}
|
||||
}
|
||||
|
||||
async function processGoogleChatEvent(event: GoogleChatEvent, target: WebhookTarget) {
|
||||
|
||||
@@ -21,6 +21,7 @@ function createWebhookRequest(params: {
|
||||
const req = new EventEmitter() as IncomingMessage & {
|
||||
destroyed?: boolean;
|
||||
destroy: (error?: Error) => IncomingMessage;
|
||||
on: (event: string, listener: (...args: unknown[]) => void) => IncomingMessage;
|
||||
};
|
||||
req.method = "POST";
|
||||
req.url = params.path ?? "/googlechat";
|
||||
@@ -29,21 +30,50 @@ function createWebhookRequest(params: {
|
||||
"content-type": "application/json",
|
||||
};
|
||||
req.destroyed = false;
|
||||
(req as unknown as { socket: { remoteAddress: string } }).socket = {
|
||||
remoteAddress: "127.0.0.1",
|
||||
};
|
||||
req.destroy = () => {
|
||||
req.destroyed = true;
|
||||
return req;
|
||||
};
|
||||
|
||||
void Promise.resolve().then(() => {
|
||||
req.emit("data", Buffer.from(JSON.stringify(params.payload), "utf-8"));
|
||||
if (!req.destroyed) {
|
||||
req.emit("end");
|
||||
const originalOn = req.on.bind(req);
|
||||
let bodyScheduled = false;
|
||||
req.on = ((event: string, listener: (...args: unknown[]) => void) => {
|
||||
const result = originalOn(event, listener);
|
||||
if (!bodyScheduled && event === "data") {
|
||||
bodyScheduled = true;
|
||||
void Promise.resolve().then(() => {
|
||||
req.emit("data", Buffer.from(JSON.stringify(params.payload), "utf-8"));
|
||||
if (!req.destroyed) {
|
||||
req.emit("end");
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}) as IncomingMessage["on"];
|
||||
|
||||
return req;
|
||||
}
|
||||
|
||||
function createHeaderOnlyWebhookRequest(params: {
|
||||
authorization?: string;
|
||||
path?: string;
|
||||
}): IncomingMessage {
|
||||
const req = new EventEmitter() as IncomingMessage;
|
||||
req.method = "POST";
|
||||
req.url = params.path ?? "/googlechat";
|
||||
req.headers = {
|
||||
authorization: params.authorization ?? "",
|
||||
"content-type": "application/json",
|
||||
};
|
||||
(req as unknown as { socket: { remoteAddress: string } }).socket = {
|
||||
remoteAddress: "127.0.0.1",
|
||||
};
|
||||
return req;
|
||||
}
|
||||
|
||||
const baseAccount = (accountId: string) =>
|
||||
({
|
||||
accountId,
|
||||
@@ -178,4 +208,59 @@ describe("Google Chat webhook routing", () => {
|
||||
unregister();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects invalid bearer before attempting to read the body", async () => {
|
||||
vi.mocked(verifyGoogleChatRequest).mockResolvedValue({ ok: false, reason: "invalid" });
|
||||
const { unregister } = registerTwoTargets();
|
||||
|
||||
try {
|
||||
const req = createHeaderOnlyWebhookRequest({
|
||||
authorization: "Bearer invalid-token",
|
||||
});
|
||||
const onSpy = vi.spyOn(req, "on");
|
||||
const res = createMockServerResponse();
|
||||
const handled = await handleGoogleChatWebhookRequest(req, res);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(onSpy).not.toHaveBeenCalledWith("data", expect.any(Function));
|
||||
} finally {
|
||||
unregister();
|
||||
}
|
||||
});
|
||||
|
||||
it("supports add-on requests that provide systemIdToken in the body", async () => {
|
||||
vi.mocked(verifyGoogleChatRequest)
|
||||
.mockResolvedValueOnce({ ok: false, reason: "invalid" })
|
||||
.mockResolvedValueOnce({ ok: true });
|
||||
const { sinkA, sinkB, unregister } = registerTwoTargets();
|
||||
|
||||
try {
|
||||
const res = createMockServerResponse();
|
||||
const handled = await handleGoogleChatWebhookRequest(
|
||||
createWebhookRequest({
|
||||
payload: {
|
||||
commonEventObject: { hostApp: "CHAT" },
|
||||
authorizationEventObject: { systemIdToken: "addon-token" },
|
||||
chat: {
|
||||
eventTime: "2026-03-02T00:00:00.000Z",
|
||||
user: { name: "users/12345", displayName: "Test User" },
|
||||
messagePayload: {
|
||||
space: { name: "spaces/AAA" },
|
||||
message: { text: "Hello from add-on" },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
res,
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(sinkA).not.toHaveBeenCalled();
|
||||
expect(sinkB).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
unregister();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
"build:plugin-sdk:dts": "tsc -p tsconfig.plugin-sdk.dts.json",
|
||||
"build:strict-smoke": "pnpm canvas:a2ui:bundle && tsdown && pnpm build:plugin-sdk:dts",
|
||||
"canvas:a2ui:bundle": "bash scripts/bundle-a2ui.sh",
|
||||
"check": "pnpm format:check && pnpm tsgo && pnpm lint && pnpm lint:tmp:no-random-messaging && pnpm lint:tmp:channel-agnostic-boundaries && pnpm lint:tmp:no-raw-channel-fetch && pnpm lint:plugins:no-register-http-handler && pnpm lint:auth:no-pairing-store-group && pnpm lint:auth:pairing-account-scope && pnpm check:host-env-policy:swift",
|
||||
"check": "pnpm format:check && pnpm tsgo && pnpm lint && pnpm lint:tmp:no-random-messaging && pnpm lint:tmp:channel-agnostic-boundaries && pnpm lint:tmp:no-raw-channel-fetch && pnpm lint:plugins:no-register-http-handler && pnpm lint:webhook:no-low-level-body-read && pnpm lint:auth:no-pairing-store-group && pnpm lint:auth:pairing-account-scope && pnpm check:host-env-policy:swift",
|
||||
"check:docs": "pnpm format:docs:check && pnpm lint:docs && pnpm docs:check-links",
|
||||
"check:host-env-policy:swift": "node scripts/generate-host-env-security-policy-swift.mjs --check",
|
||||
"check:loc": "node --import tsx scripts/check-ts-max-loc.ts --max 500",
|
||||
@@ -108,6 +108,7 @@
|
||||
"lint:tmp:no-random-messaging": "node scripts/check-no-random-messaging-tmp.mjs",
|
||||
"lint:tmp:no-raw-channel-fetch": "node scripts/check-no-raw-channel-fetch.mjs",
|
||||
"lint:ui:no-raw-window-open": "node scripts/check-no-raw-window-open.mjs",
|
||||
"lint:webhook:no-low-level-body-read": "node scripts/check-webhook-auth-body-order.mjs",
|
||||
"mac:open": "open dist/OpenClaw.app",
|
||||
"mac:package": "bash scripts/package-mac-app.sh",
|
||||
"mac:restart": "bash scripts/restart-mac.sh",
|
||||
|
||||
54
scripts/check-webhook-auth-body-order.mjs
Normal file
54
scripts/check-webhook-auth-body-order.mjs
Normal file
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import path from "node:path";
|
||||
import ts from "typescript";
|
||||
import { runCallsiteGuard } from "./lib/callsite-guard.mjs";
|
||||
import { runAsScript, toLine, unwrapExpression } from "./lib/ts-guard-utils.mjs";
|
||||
|
||||
const sourceRoots = ["extensions"];
|
||||
const enforcedFiles = new Set([
|
||||
"extensions/bluebubbles/src/monitor.ts",
|
||||
"extensions/googlechat/src/monitor.ts",
|
||||
]);
|
||||
const blockedCallees = new Set(["readJsonBodyWithLimit", "readRequestBodyWithLimit"]);
|
||||
|
||||
function getCalleeName(expression) {
|
||||
const callee = unwrapExpression(expression);
|
||||
if (ts.isIdentifier(callee)) {
|
||||
return callee.text;
|
||||
}
|
||||
if (ts.isPropertyAccessExpression(callee)) {
|
||||
return callee.name.text;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function findBlockedWebhookBodyReadLines(content, fileName = "source.ts") {
|
||||
const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true);
|
||||
const lines = [];
|
||||
const visit = (node) => {
|
||||
if (ts.isCallExpression(node)) {
|
||||
const calleeName = getCalleeName(node.expression);
|
||||
if (calleeName && blockedCallees.has(calleeName)) {
|
||||
lines.push(toLine(sourceFile, node.expression));
|
||||
}
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
};
|
||||
visit(sourceFile);
|
||||
return lines;
|
||||
}
|
||||
|
||||
export async function main() {
|
||||
await runCallsiteGuard({
|
||||
importMetaUrl: import.meta.url,
|
||||
sourceRoots,
|
||||
findCallLines: findBlockedWebhookBodyReadLines,
|
||||
skipRelativePath: (relPath) => !enforcedFiles.has(relPath.replaceAll(path.sep, "/")),
|
||||
header: "Found forbidden low-level body reads in auth-sensitive webhook handlers:",
|
||||
footer:
|
||||
"Use plugin-sdk webhook guards (`readJsonWebhookBodyOrReject` / `readWebhookBodyOrReject`) with explicit pre-auth/post-auth profiles.",
|
||||
});
|
||||
}
|
||||
|
||||
runAsScript(import.meta.url, main);
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user