Files
openclaw/extensions/bluebubbles/src/monitor.ts
Jacob Tomlinson 5e08ce36d5 fix(bluebubbles): throttle webhook auth guesses (#55133)
* fix(bluebubbles): throttle webhook auth guesses

* test(bluebubbles): isolate attachment ssrf config

* test(bluebubbles): hoist attachment mocks

* docs: refresh bluebubbles config baseline

* fix(bluebubbles): trust proxied webhook client IPs

* fix(bluebubbles): honor trusted proxy webhook IPs

* fix(bluebubbles): honor real-ip fallback for webhooks
2026-03-26 14:54:03 +00:00

374 lines
12 KiB
TypeScript

import { timingSafeEqual } from "node:crypto";
import type { IncomingMessage, ServerResponse } from "node:http";
import { createBlueBubblesDebounceRegistry } from "./monitor-debounce.js";
import { normalizeWebhookMessage, normalizeWebhookReaction } from "./monitor-normalize.js";
import { logVerbose, processMessage, processReaction } from "./monitor-processing.js";
import {
_resetBlueBubblesShortIdState,
resolveBlueBubblesMessageId,
} from "./monitor-reply-cache.js";
import {
DEFAULT_WEBHOOK_PATH,
normalizeWebhookPath,
resolveWebhookPathFromConfig,
type BlueBubblesMonitorOptions,
type WebhookTarget,
} from "./monitor-shared.js";
import { fetchBlueBubblesServerInfo } from "./probe.js";
import {
WEBHOOK_RATE_LIMIT_DEFAULTS,
createFixedWindowRateLimiter,
createWebhookInFlightLimiter,
registerWebhookTargetWithPluginRoute,
readWebhookBodyOrReject,
resolveRequestClientIp,
resolveWebhookTargetWithAuthOrRejectSync,
withResolvedWebhookRequestPipeline,
} from "./runtime-api.js";
import { getBlueBubblesRuntime } from "./runtime.js";
const webhookTargets = new Map<string, WebhookTarget[]>();
const webhookRateLimiter = createFixedWindowRateLimiter({
windowMs: WEBHOOK_RATE_LIMIT_DEFAULTS.windowMs,
maxRequests: WEBHOOK_RATE_LIMIT_DEFAULTS.maxRequests,
maxTrackedKeys: WEBHOOK_RATE_LIMIT_DEFAULTS.maxTrackedKeys,
});
const webhookInFlightLimiter = createWebhookInFlightLimiter();
const debounceRegistry = createBlueBubblesDebounceRegistry({ processMessage });
export function clearBlueBubblesWebhookSecurityStateForTest(): void {
webhookRateLimiter.clear();
webhookInFlightLimiter.clear();
}
export function registerBlueBubblesWebhookTarget(target: WebhookTarget): () => void {
const registered = registerWebhookTargetWithPluginRoute({
targetsByPath: webhookTargets,
target,
route: {
auth: "plugin",
match: "exact",
pluginId: "bluebubbles",
source: "bluebubbles-webhook",
accountId: target.account.accountId,
log: target.runtime.log,
handler: async (req, res) => {
const handled = await handleBlueBubblesWebhookRequest(req, res);
if (!handled && !res.headersSent) {
res.statusCode = 404;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end("Not Found");
}
},
},
});
return () => {
registered.unregister();
// Clean up debouncer when target is unregistered
debounceRegistry.removeDebouncer(registered.target);
};
}
function parseBlueBubblesWebhookPayload(
rawBody: string,
): { ok: true; value: unknown } | { ok: false; error: string } {
const trimmed = rawBody.trim();
if (!trimmed) {
return { ok: false, error: "empty payload" };
}
try {
return { ok: true, value: JSON.parse(trimmed) as unknown };
} catch {
const params = new URLSearchParams(rawBody);
const payload = params.get("payload") ?? params.get("data") ?? params.get("message");
if (!payload) {
return { ok: false, error: "invalid json" };
}
try {
return { ok: true, value: JSON.parse(payload) as unknown };
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : String(error) };
}
}
}
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function maskSecret(value: string): string {
if (value.length <= 6) {
return "***";
}
return `${value.slice(0, 2)}***${value.slice(-2)}`;
}
function normalizeAuthToken(raw: string): string {
const value = raw.trim();
if (!value) {
return "";
}
if (value.toLowerCase().startsWith("bearer ")) {
return value.slice("bearer ".length).trim();
}
return value;
}
function safeEqualSecret(aRaw: string, bRaw: string): boolean {
const a = normalizeAuthToken(aRaw);
const b = normalizeAuthToken(bRaw);
if (!a || !b) {
return false;
}
const bufA = Buffer.from(a, "utf8");
const bufB = Buffer.from(b, "utf8");
if (bufA.length !== bufB.length) {
return false;
}
return timingSafeEqual(bufA, bufB);
}
function collectTrustedProxies(targets: readonly WebhookTarget[]): string[] {
const proxies = new Set<string>();
for (const target of targets) {
for (const proxy of target.config.gateway?.trustedProxies ?? []) {
const normalized = proxy.trim();
if (normalized) {
proxies.add(normalized);
}
}
}
return [...proxies];
}
function resolveWebhookAllowRealIpFallback(targets: readonly WebhookTarget[]): boolean {
return targets.some((target) => target.config.gateway?.allowRealIpFallback === true);
}
function resolveWebhookClientIp(
req: IncomingMessage,
trustedProxies: readonly string[],
allowRealIpFallback: boolean,
): string {
if (!req.headers["x-forwarded-for"] && !(allowRealIpFallback && req.headers["x-real-ip"])) {
return req.socket.remoteAddress ?? "unknown";
}
// Mirror gateway client-IP trust rules so limiter buckets follow configured proxy hops.
return (
resolveRequestClientIp(req, [...trustedProxies], allowRealIpFallback) ??
req.socket.remoteAddress ??
"unknown"
);
}
export async function handleBlueBubblesWebhookRequest(
req: IncomingMessage,
res: ServerResponse,
): Promise<boolean> {
const requestUrl = new URL(req.url ?? "/", "http://localhost");
const normalizedPath = normalizeWebhookPath(requestUrl.pathname);
const pathTargets = webhookTargets.get(normalizedPath) ?? [];
const trustedProxies = collectTrustedProxies(pathTargets);
const allowRealIpFallback = resolveWebhookAllowRealIpFallback(pathTargets);
const clientIp = resolveWebhookClientIp(req, trustedProxies, allowRealIpFallback);
const rateLimitKey = `${normalizedPath}:${clientIp}`;
return await withResolvedWebhookRequestPipeline({
req,
res,
targetsByPath: webhookTargets,
allowMethods: ["POST"],
rateLimiter: webhookRateLimiter,
rateLimitKey,
inFlightLimiter: webhookInFlightLimiter,
inFlightKey: `${normalizedPath}:${clientIp}`,
handle: async ({ path, targets }) => {
const url = requestUrl;
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 target = resolveWebhookTargetWithAuthOrRejectSync({
targets,
res,
isMatch: (target) => {
const token = target.account.config.password?.trim() ?? "";
return safeEqualSecret(guid, token);
},
});
if (!target) {
console.warn(
`[bluebubbles] webhook rejected: status=${res.statusCode} path=${path} guid=${maskSecret(url.searchParams.get("guid") ?? url.searchParams.get("password") ?? "")}`,
);
return true;
}
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 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;
}
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 = debounceRegistry.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;
},
});
}
export async function monitorBlueBubblesProvider(
options: BlueBubblesMonitorOptions,
): Promise<void> {
const { account, config, runtime, abortSignal, statusSink } = options;
const core = getBlueBubblesRuntime();
const path = options.webhookPath?.trim() || DEFAULT_WEBHOOK_PATH;
// Fetch and cache server info (for macOS version detection in action gating)
const serverInfo = await fetchBlueBubblesServerInfo({
baseUrl: account.baseUrl,
password: account.config.password,
accountId: account.accountId,
timeoutMs: 5000,
allowPrivateNetwork: account.config.allowPrivateNetwork === true,
}).catch(() => null);
if (serverInfo?.os_version) {
runtime.log?.(`[${account.accountId}] BlueBubbles server macOS ${serverInfo.os_version}`);
}
if (typeof serverInfo?.private_api === "boolean") {
runtime.log?.(
`[${account.accountId}] BlueBubbles Private API ${serverInfo.private_api ? "enabled" : "disabled"}`,
);
}
const unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime,
core,
path,
statusSink,
});
return await new Promise((resolve) => {
const stop = () => {
unregister();
resolve();
};
if (abortSignal?.aborted) {
stop();
return;
}
abortSignal?.addEventListener("abort", stop, { once: true });
runtime.log?.(
`[${account.accountId}] BlueBubbles webhook listening on ${normalizeWebhookPath(path)}`,
);
});
}
export { _resetBlueBubblesShortIdState, resolveBlueBubblesMessageId, resolveWebhookPathFromConfig };