fix: harden webhook auth-before-body handling

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

View File

@@ -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.

View File

@@ -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)

View File

@@ -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.

View File

@@ -48,6 +48,10 @@ The gateway responds to LINEs 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:

View File

@@ -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 = {};

View File

@@ -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(

View File

@@ -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) {

View File

@@ -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();
}
});
});

View File

@@ -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",

View 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);

View File

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

View File

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

View File

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

View File

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

View File

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