Moderate severityNVD Advisory· Published Mar 21, 2026· Updated Mar 23, 2026
OpenClaw < 2026.2.21 - Unauthenticated Webhook Access via Passwordless Fallback in BlueBubbles Plugin
CVE-2026-32896
Description
OpenClaw versions prior to 2026.2.21 BlueBubbles webhook handler contains a passwordless fallback authentication path that allows unauthenticated webhook events in certain reverse-proxy or local routing configurations. Attackers can bypass webhook authentication by exploiting the loopback/proxy heuristics to send unauthenticated webhook events to the BlueBubbles plugin.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
openclawnpm | < 2026.2.21 | 2026.2.21 |
Affected products
1Patches
2283029bdea23refactor(security): unify webhook auth matching paths
9 files changed · +374 −130
extensions/bluebubbles/src/config-schema.test.ts+55 −0 added@@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { BlueBubblesConfigSchema } from "./config-schema.js"; + +describe("BlueBubblesConfigSchema", () => { + it("accepts account config when serverUrl and password are both set", () => { + const parsed = BlueBubblesConfigSchema.safeParse({ + serverUrl: "http://localhost:1234", + password: "secret", + }); + expect(parsed.success).toBe(true); + }); + + it("requires password when top-level serverUrl is configured", () => { + const parsed = BlueBubblesConfigSchema.safeParse({ + serverUrl: "http://localhost:1234", + }); + expect(parsed.success).toBe(false); + if (parsed.success) { + return; + } + expect(parsed.error.issues[0]?.path).toEqual(["password"]); + expect(parsed.error.issues[0]?.message).toBe( + "password is required when serverUrl is configured", + ); + }); + + it("requires password when account serverUrl is configured", () => { + const parsed = BlueBubblesConfigSchema.safeParse({ + accounts: { + work: { + serverUrl: "http://localhost:1234", + }, + }, + }); + expect(parsed.success).toBe(false); + if (parsed.success) { + return; + } + expect(parsed.error.issues[0]?.path).toEqual(["accounts", "work", "password"]); + expect(parsed.error.issues[0]?.message).toBe( + "password is required when serverUrl is configured", + ); + }); + + it("allows password omission when serverUrl is not configured", () => { + const parsed = BlueBubblesConfigSchema.safeParse({ + accounts: { + work: { + name: "Work iMessage", + }, + }, + }); + expect(parsed.success).toBe(true); + }); +});
extensions/bluebubbles/src/config-schema.ts+33 −21 modified@@ -24,27 +24,39 @@ const bluebubblesGroupConfigSchema = z.object({ tools: ToolPolicySchema, }); -const bluebubblesAccountSchema = z.object({ - name: z.string().optional(), - enabled: z.boolean().optional(), - markdown: MarkdownConfigSchema, - serverUrl: z.string().optional(), - password: z.string().optional(), - webhookPath: z.string().optional(), - dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(), - allowFrom: z.array(allowFromEntry).optional(), - groupAllowFrom: z.array(allowFromEntry).optional(), - groupPolicy: z.enum(["open", "disabled", "allowlist"]).optional(), - historyLimit: z.number().int().min(0).optional(), - dmHistoryLimit: z.number().int().min(0).optional(), - textChunkLimit: z.number().int().positive().optional(), - chunkMode: z.enum(["length", "newline"]).optional(), - mediaMaxMb: z.number().int().positive().optional(), - mediaLocalRoots: z.array(z.string()).optional(), - sendReadReceipts: z.boolean().optional(), - blockStreaming: z.boolean().optional(), - groups: z.object({}).catchall(bluebubblesGroupConfigSchema).optional(), -}); +const bluebubblesAccountSchema = z + .object({ + name: z.string().optional(), + enabled: z.boolean().optional(), + markdown: MarkdownConfigSchema, + serverUrl: z.string().optional(), + password: z.string().optional(), + webhookPath: z.string().optional(), + dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(), + allowFrom: z.array(allowFromEntry).optional(), + groupAllowFrom: z.array(allowFromEntry).optional(), + groupPolicy: z.enum(["open", "disabled", "allowlist"]).optional(), + historyLimit: z.number().int().min(0).optional(), + dmHistoryLimit: z.number().int().min(0).optional(), + textChunkLimit: z.number().int().positive().optional(), + chunkMode: z.enum(["length", "newline"]).optional(), + mediaMaxMb: z.number().int().positive().optional(), + mediaLocalRoots: z.array(z.string()).optional(), + sendReadReceipts: z.boolean().optional(), + blockStreaming: z.boolean().optional(), + groups: z.object({}).catchall(bluebubblesGroupConfigSchema).optional(), + }) + .superRefine((value, ctx) => { + const serverUrl = value.serverUrl?.trim() ?? ""; + const password = value.password?.trim() ?? ""; + if (serverUrl && !password) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["password"], + message: "password is required when serverUrl is configured", + }); + } + }); export const BlueBubblesConfigSchema = bluebubblesAccountSchema.extend({ accounts: z.object({}).catchall(bluebubblesAccountSchema).optional(),
extensions/bluebubbles/src/monitor.test.ts+39 −0 modified@@ -452,6 +452,45 @@ describe("BlueBubbles webhook monitor", () => { expect(res.statusCode).toBe(400); }); + it("accepts URL-encoded payload wrappers", async () => { + const account = createMockAccount(); + const config: OpenClawConfig = {}; + const core = createMockRuntime(); + setBlueBubblesRuntime(core); + + unregister = registerBlueBubblesWebhookTarget({ + account, + config, + runtime: { log: vi.fn(), error: vi.fn() }, + core, + path: "/bluebubbles-webhook", + }); + + const payload = { + type: "new-message", + data: { + text: "hello", + handle: { address: "+15551234567" }, + isGroup: false, + isFromMe: false, + guid: "msg-1", + date: Date.now(), + }, + }; + const encodedBody = new URLSearchParams({ + payload: JSON.stringify(payload), + }).toString(); + + const req = createMockRequest("POST", "/bluebubbles-webhook", encodedBody); + const res = createMockResponse(); + + const handled = await handleBlueBubblesWebhookRequest(req, res); + + expect(handled).toBe(true); + expect(res.statusCode).toBe(200); + expect(res.body).toBe("ok"); + }); + it("returns 408 when request body times out (Slow-Loris protection)", async () => { vi.useFakeTimers(); try {
extensions/bluebubbles/src/monitor.ts+65 −93 modified@@ -2,8 +2,12 @@ import { timingSafeEqual } from "node:crypto"; import type { IncomingMessage, ServerResponse } from "node:http"; import type { OpenClawConfig } from "openclaw/plugin-sdk"; import { + isRequestBodyLimitError, + readRequestBodyWithLimit, registerWebhookTarget, rejectNonPostWebhookRequest, + requestBodyErrorToText, + resolveSingleWebhookTarget, resolveWebhookTargets, } from "openclaw/plugin-sdk"; import { @@ -231,12 +235,6 @@ function removeDebouncer(target: WebhookTarget): void { } export function registerBlueBubblesWebhookTarget(target: WebhookTarget): () => void { - const webhookPassword = target.account.config.password?.trim() ?? ""; - if (!webhookPassword) { - target.runtime.error?.( - `[${target.account.accountId}] BlueBubbles webhook auth requires channels.bluebubbles.password. Configure a password and include it in the webhook URL.`, - ); - } const registered = registerWebhookTarget(webhookTargets, target); return () => { registered.unregister(); @@ -245,64 +243,61 @@ export function registerBlueBubblesWebhookTarget(target: WebhookTarget): () => v }; } -async function readJsonBody(req: IncomingMessage, maxBytes: number, timeoutMs = 30_000) { - const chunks: Buffer[] = []; - let total = 0; - return await new Promise<{ ok: boolean; value?: unknown; error?: string }>((resolve) => { - let done = false; - const finish = (result: { ok: boolean; value?: unknown; error?: string }) => { - if (done) { - return; - } - done = true; - clearTimeout(timer); - resolve(result); - }; +type ReadBlueBubblesWebhookBodyResult = + | { ok: true; value: unknown } + | { ok: false; statusCode: number; error: string }; - const timer = setTimeout(() => { - finish({ ok: false, error: "request body timeout" }); - req.destroy(); - }, timeoutMs); +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) }; + } + } +} - req.on("data", (chunk: Buffer) => { - total += chunk.length; - if (total > maxBytes) { - finish({ ok: false, error: "payload too large" }); - req.destroy(); - return; - } - chunks.push(chunk); - }); - req.on("end", () => { - try { - const raw = Buffer.concat(chunks).toString("utf8"); - if (!raw.trim()) { - finish({ ok: false, error: "empty payload" }); - return; - } - try { - finish({ ok: true, value: JSON.parse(raw) as unknown }); - return; - } catch { - const params = new URLSearchParams(raw); - const payload = params.get("payload") ?? params.get("data") ?? params.get("message"); - if (payload) { - finish({ ok: true, value: JSON.parse(payload) as unknown }); - return; - } - throw new Error("invalid json"); - } - } catch (err) { - finish({ ok: false, error: err instanceof Error ? err.message : String(err) }); - } - }); - req.on("error", (err) => { - finish({ ok: false, error: err instanceof Error ? err.message : String(err) }); - }); - req.on("close", () => { - finish({ ok: false, error: "connection closed" }); +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 { @@ -343,26 +338,6 @@ function safeEqualSecret(aRaw: string, bRaw: string): boolean { return timingSafeEqual(bufA, bufB); } -function resolveAuthenticatedWebhookTargets( - targets: WebhookTarget[], - presentedToken: string, -): WebhookTarget[] { - const matches: WebhookTarget[] = []; - for (const target of targets) { - const token = target.account.config.password?.trim() ?? ""; - if (!token) { - continue; - } - if (safeEqualSecret(presentedToken, token)) { - matches.push(target); - if (matches.length > 1) { - break; - } - } - } - return matches; -} - export async function handleBlueBubblesWebhookRequest( req: IncomingMessage, res: ServerResponse, @@ -378,15 +353,9 @@ export async function handleBlueBubblesWebhookRequest( return true; } - const body = await readJsonBody(req, 1024 * 1024); + const body = await readBlueBubblesWebhookBody(req, 1024 * 1024); if (!body.ok) { - if (body.error === "payload too large") { - res.statusCode = 413; - } else if (body.error === "request body timeout") { - res.statusCode = 408; - } else { - res.statusCode = 400; - } + res.statusCode = body.statusCode; res.end(body.error ?? "invalid payload"); console.warn(`[bluebubbles] webhook rejected: ${body.error ?? "invalid payload"}`); return true; @@ -450,9 +419,12 @@ export async function handleBlueBubblesWebhookRequest( req.headers["x-bluebubbles-guid"] ?? req.headers["authorization"]; const guid = (Array.isArray(headerToken) ? headerToken[0] : headerToken) ?? guidParam ?? ""; - const matching = resolveAuthenticatedWebhookTargets(targets, guid); + const matchedTarget = resolveSingleWebhookTarget(targets, (target) => { + const token = target.account.config.password?.trim() ?? ""; + return safeEqualSecret(guid, token); + }); - if (matching.length === 0) { + if (matchedTarget.kind === "none") { res.statusCode = 401; res.end("unauthorized"); console.warn( @@ -461,14 +433,14 @@ export async function handleBlueBubblesWebhookRequest( return true; } - if (matching.length > 1) { + 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 = matching[0]; + const target = matchedTarget.target; target.statusSink?.({ lastInboundAt: Date.now() }); if (reaction) { processReaction(reaction, target).catch((err) => {
extensions/googlechat/src/monitor.ts+7 −12 modified@@ -5,6 +5,7 @@ import { readJsonBodyWithLimit, registerWebhookTarget, rejectNonPostWebhookRequest, + resolveSingleWebhookTargetAsync, resolveWebhookPath, resolveWebhookTargets, requestBodyErrorToText, @@ -208,36 +209,30 @@ export async function handleGoogleChatWebhookRequest( ? authHeaderNow.slice("bearer ".length) : bearer; - const matchedTargets: WebhookTarget[] = []; - for (const target of targets) { + const matchedTarget = await resolveSingleWebhookTargetAsync(targets, async (target) => { const audienceType = target.audienceType; const audience = target.audience; const verification = await verifyGoogleChatRequest({ bearer: effectiveBearer, audienceType, audience, }); - if (verification.ok) { - matchedTargets.push(target); - if (matchedTargets.length > 1) { - break; - } - } - } + return verification.ok; + }); - if (matchedTargets.length === 0) { + if (matchedTarget.kind === "none") { res.statusCode = 401; res.end("unauthorized"); return true; } - if (matchedTargets.length > 1) { + if (matchedTarget.kind === "ambiguous") { res.statusCode = 401; res.end("ambiguous webhook target"); return true; } - const selected = matchedTargets[0]; + const selected = matchedTarget.target; selected.statusSink?.({ lastInboundAt: Date.now() }); processGoogleChatEvent(event, selected).catch((err) => { selected?.runtime.error?.(
extensions/zalo/src/monitor.ts+7 −4 modified@@ -6,6 +6,7 @@ import { readJsonBodyWithLimit, registerWebhookTarget, rejectNonPostWebhookRequest, + resolveSingleWebhookTarget, resolveSenderCommandAuthorization, resolveWebhookPath, resolveWebhookTargets, @@ -195,20 +196,22 @@ export async function handleZaloWebhookRequest( } const headerToken = String(req.headers["x-bot-api-secret-token"] ?? ""); - const matching = targets.filter((entry) => timingSafeEquals(entry.secret, headerToken)); - if (matching.length === 0) { + const matchedTarget = resolveSingleWebhookTarget(targets, (entry) => + timingSafeEquals(entry.secret, headerToken), + ); + if (matchedTarget.kind === "none") { res.statusCode = 401; res.end("unauthorized"); recordWebhookStatus(targets[0]?.runtime, req.url ?? "<unknown>", res.statusCode); return true; } - if (matching.length > 1) { + if (matchedTarget.kind === "ambiguous") { res.statusCode = 401; res.end("ambiguous webhook target"); recordWebhookStatus(targets[0]?.runtime, req.url ?? "<unknown>", res.statusCode); return true; } - const target = matching[0]; + const target = matchedTarget.target; const path = req.url ?? "<unknown>"; const rateLimitKey = `${path}:${req.socket.remoteAddress ?? "unknown"}`; const nowMs = Date.now();
src/plugin-sdk/index.ts+3 −0 modified@@ -88,8 +88,11 @@ export { normalizeWebhookPath, resolveWebhookPath } from "./webhook-path.js"; export { registerWebhookTarget, rejectNonPostWebhookRequest, + resolveSingleWebhookTarget, + resolveSingleWebhookTargetAsync, resolveWebhookTargets, } from "./webhook-targets.js"; +export type { WebhookTargetMatchResult } from "./webhook-targets.js"; export type { AgentMediaPayload } from "./agent-media-payload.js"; export { buildAgentMediaPayload } from "./agent-media-payload.js"; export {
src/plugin-sdk/webhook-targets.test.ts+120 −0 added@@ -0,0 +1,120 @@ +import { EventEmitter } from "node:events"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import { describe, expect, it, vi } from "vitest"; +import { + registerWebhookTarget, + rejectNonPostWebhookRequest, + resolveSingleWebhookTarget, + resolveSingleWebhookTargetAsync, + resolveWebhookTargets, +} from "./webhook-targets.js"; + +function createRequest(method: string, url: string): IncomingMessage { + const req = new EventEmitter() as IncomingMessage; + req.method = method; + req.url = url; + req.headers = {}; + return req; +} + +describe("registerWebhookTarget", () => { + it("normalizes the path and unregisters cleanly", () => { + const targets = new Map<string, Array<{ path: string; id: string }>>(); + const registered = registerWebhookTarget(targets, { + path: "hook", + id: "A", + }); + + expect(registered.target.path).toBe("/hook"); + expect(targets.get("/hook")).toEqual([registered.target]); + + registered.unregister(); + expect(targets.has("/hook")).toBe(false); + }); +}); + +describe("resolveWebhookTargets", () => { + it("resolves normalized path targets", () => { + const targets = new Map<string, Array<{ id: string }>>(); + targets.set("/hook", [{ id: "A" }]); + + expect(resolveWebhookTargets(createRequest("POST", "/hook/"), targets)).toEqual({ + path: "/hook", + targets: [{ id: "A" }], + }); + }); + + it("returns null when path has no targets", () => { + const targets = new Map<string, Array<{ id: string }>>(); + expect(resolveWebhookTargets(createRequest("POST", "/missing"), targets)).toBeNull(); + }); +}); + +describe("rejectNonPostWebhookRequest", () => { + it("sets 405 for non-POST requests", () => { + const setHeaderMock = vi.fn(); + const endMock = vi.fn(); + const res = { + statusCode: 200, + setHeader: setHeaderMock, + end: endMock, + } as unknown as ServerResponse; + + const rejected = rejectNonPostWebhookRequest(createRequest("GET", "/hook"), res); + + expect(rejected).toBe(true); + expect(res.statusCode).toBe(405); + expect(setHeaderMock).toHaveBeenCalledWith("Allow", "POST"); + expect(endMock).toHaveBeenCalledWith("Method Not Allowed"); + }); +}); + +describe("resolveSingleWebhookTarget", () => { + it("returns none when no target matches", () => { + const result = resolveSingleWebhookTarget(["a", "b"], (value) => value === "c"); + expect(result).toEqual({ kind: "none" }); + }); + + it("returns the single match", () => { + const result = resolveSingleWebhookTarget(["a", "b"], (value) => value === "b"); + expect(result).toEqual({ kind: "single", target: "b" }); + }); + + it("returns ambiguous after second match", () => { + const calls: string[] = []; + const result = resolveSingleWebhookTarget(["a", "b", "c"], (value) => { + calls.push(value); + return value === "a" || value === "b"; + }); + expect(result).toEqual({ kind: "ambiguous" }); + expect(calls).toEqual(["a", "b"]); + }); +}); + +describe("resolveSingleWebhookTargetAsync", () => { + it("returns none when no target matches", async () => { + const result = await resolveSingleWebhookTargetAsync( + ["a", "b"], + async (value) => value === "c", + ); + expect(result).toEqual({ kind: "none" }); + }); + + it("returns the single async match", async () => { + const result = await resolveSingleWebhookTargetAsync( + ["a", "b"], + async (value) => value === "b", + ); + expect(result).toEqual({ kind: "single", target: "b" }); + }); + + it("returns ambiguous after second async match", async () => { + const calls: string[] = []; + const result = await resolveSingleWebhookTargetAsync(["a", "b", "c"], async (value) => { + calls.push(value); + return value === "a" || value === "b"; + }); + expect(result).toEqual({ kind: "ambiguous" }); + expect(calls).toEqual(["a", "b"]); + }); +});
src/plugin-sdk/webhook-targets.ts+45 −0 modified@@ -38,6 +38,51 @@ export function resolveWebhookTargets<T>( return { path, targets }; } +export type WebhookTargetMatchResult<T> = + | { kind: "none" } + | { kind: "single"; target: T } + | { kind: "ambiguous" }; + +export function resolveSingleWebhookTarget<T>( + targets: readonly T[], + isMatch: (target: T) => boolean, +): WebhookTargetMatchResult<T> { + let matched: T | undefined; + for (const target of targets) { + if (!isMatch(target)) { + continue; + } + if (matched) { + return { kind: "ambiguous" }; + } + matched = target; + } + if (!matched) { + return { kind: "none" }; + } + return { kind: "single", target: matched }; +} + +export async function resolveSingleWebhookTargetAsync<T>( + targets: readonly T[], + isMatch: (target: T) => Promise<boolean>, +): Promise<WebhookTargetMatchResult<T>> { + let matched: T | undefined; + for (const target of targets) { + if (!(await isMatch(target))) { + continue; + } + if (matched) { + return { kind: "ambiguous" }; + } + matched = target; + } + if (!matched) { + return { kind: "none" }; + } + return { kind: "single", target: matched }; +} + export function rejectNonPostWebhookRequest(req: IncomingMessage, res: ServerResponse): boolean { if (req.method === "POST") { return false;
6b2f2811dc62fix(security): require BlueBubbles webhook auth
4 files changed · +64 −128
CHANGELOG.md+1 −0 modified@@ -29,6 +29,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- BlueBubbles/Security (optional beta iMessage plugin): require webhook token authentication for all BlueBubbles webhook requests (including loopback/proxied setups), removing passwordless webhook fallback behavior. Thanks @zpbrent. - iOS/Security: force `https://` for non-loopback manual gateway hosts during iOS onboarding to block insecure remote transport URLs. (#21969) Thanks @mbelinky. - Gateway/Security: remove shared-IP fallback for canvas endpoints and require token or session capability for canvas access. Thanks @thewilloftheshadow. - Gateway/Security: require secure context and paired-device checks for Control UI auth even when `gateway.controlUi.allowInsecureAuth` is set, and align audit messaging with the hardened behavior. (#20684) thanks @coygeek.
docs/channels/bluebubbles.md+2 −1 modified@@ -46,7 +46,8 @@ Status: bundled plugin that talks to the BlueBubbles macOS server over HTTP. **R Security note: -- Always set a webhook password. If you expose the gateway through a reverse proxy (Tailscale Serve/Funnel, nginx, Cloudflare Tunnel, ngrok), the proxy may connect to the gateway over loopback. The BlueBubbles webhook handler treats requests with forwarding headers as proxied and will not accept passwordless webhooks. +- 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. ## Keeping Messages.app alive (VM / headless setups)
extensions/bluebubbles/src/monitor.test.ts+38 −66 modified@@ -659,15 +659,15 @@ describe("BlueBubbles webhook monitor", () => { expect(sinkB).not.toHaveBeenCalled(); }); - it("does not route to passwordless targets when a password-authenticated target matches", async () => { + it("ignores targets without passwords when a password-authenticated target matches", async () => { const accountStrict = createMockAccount({ password: "secret-token" }); - const accountFallback = createMockAccount({ password: undefined }); + const accountWithoutPassword = createMockAccount({ password: undefined }); const config: OpenClawConfig = {}; const core = createMockRuntime(); setBlueBubblesRuntime(core); const sinkStrict = vi.fn(); - const sinkFallback = vi.fn(); + const sinkWithoutPassword = vi.fn(); const req = createMockRequest("POST", "/bluebubbles-webhook?password=secret-token", { type: "new-message", @@ -691,17 +691,17 @@ describe("BlueBubbles webhook monitor", () => { path: "/bluebubbles-webhook", statusSink: sinkStrict, }); - const unregisterFallback = registerBlueBubblesWebhookTarget({ - account: accountFallback, + const unregisterNoPassword = registerBlueBubblesWebhookTarget({ + account: accountWithoutPassword, config, runtime: { log: vi.fn(), error: vi.fn() }, core, path: "/bluebubbles-webhook", - statusSink: sinkFallback, + statusSink: sinkWithoutPassword, }); unregister = () => { unregisterStrict(); - unregisterFallback(); + unregisterNoPassword(); }; const res = createMockResponse(); @@ -710,7 +710,7 @@ describe("BlueBubbles webhook monitor", () => { expect(handled).toBe(true); expect(res.statusCode).toBe(200); expect(sinkStrict).toHaveBeenCalledTimes(1); - expect(sinkFallback).not.toHaveBeenCalled(); + expect(sinkWithoutPassword).not.toHaveBeenCalled(); }); it("requires authentication for loopback requests when password is configured", async () => { @@ -750,31 +750,12 @@ describe("BlueBubbles webhook monitor", () => { } }); - it("rejects passwordless targets when the request looks proxied (has forwarding headers)", async () => { + it("rejects targets without passwords for loopback and proxied-looking requests", async () => { const account = createMockAccount({ password: undefined }); const config: OpenClawConfig = {}; const core = createMockRuntime(); setBlueBubblesRuntime(core); - const req = createMockRequest( - "POST", - "/bluebubbles-webhook", - { - type: "new-message", - data: { - text: "hello", - handle: { address: "+15551234567" }, - isGroup: false, - isFromMe: false, - guid: "msg-1", - }, - }, - { "x-forwarded-for": "203.0.113.10", host: "localhost" }, - ); - (req as unknown as { socket: { remoteAddress: string } }).socket = { - remoteAddress: "127.0.0.1", - }; - unregister = registerBlueBubblesWebhookTarget({ account, config, @@ -783,44 +764,35 @@ describe("BlueBubbles webhook monitor", () => { path: "/bluebubbles-webhook", }); - const res = createMockResponse(); - const handled = await handleBlueBubblesWebhookRequest(req, res); - expect(handled).toBe(true); - expect(res.statusCode).toBe(401); - }); - - it("accepts passwordless targets for direct localhost loopback requests (no forwarding headers)", async () => { - const account = createMockAccount({ password: undefined }); - const config: OpenClawConfig = {}; - const core = createMockRuntime(); - setBlueBubblesRuntime(core); - - const req = createMockRequest("POST", "/bluebubbles-webhook", { - type: "new-message", - data: { - text: "hello", - handle: { address: "+15551234567" }, - isGroup: false, - isFromMe: false, - guid: "msg-1", - }, - }); - (req as unknown as { socket: { remoteAddress: string } }).socket = { - remoteAddress: "127.0.0.1", - }; - - unregister = registerBlueBubblesWebhookTarget({ - account, - config, - runtime: { log: vi.fn(), error: vi.fn() }, - core, - path: "/bluebubbles-webhook", - }); - - const res = createMockResponse(); - const handled = await handleBlueBubblesWebhookRequest(req, res); - expect(handled).toBe(true); - expect(res.statusCode).toBe(200); + const headerVariants: Record<string, string>[] = [ + { host: "localhost" }, + { host: "localhost", "x-forwarded-for": "203.0.113.10" }, + { host: "localhost", forwarded: "for=203.0.113.10;proto=https;host=example.com" }, + ]; + for (const headers of headerVariants) { + const req = createMockRequest( + "POST", + "/bluebubbles-webhook", + { + type: "new-message", + data: { + text: "hello", + handle: { address: "+15551234567" }, + isGroup: false, + isFromMe: false, + guid: "msg-1", + }, + }, + headers, + ); + (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); + } }); it("ignores unregistered webhook paths", async () => {
extensions/bluebubbles/src/monitor.ts+23 −61 modified@@ -231,6 +231,12 @@ function removeDebouncer(target: WebhookTarget): void { } export function registerBlueBubblesWebhookTarget(target: WebhookTarget): () => void { + const webhookPassword = target.account.config.password?.trim() ?? ""; + if (!webhookPassword) { + target.runtime.error?.( + `[${target.account.accountId}] BlueBubbles webhook auth requires channels.bluebubbles.password. Configure a password and include it in the webhook URL.`, + ); + } const registered = registerWebhookTarget(webhookTargets, target); return () => { registered.unregister(); @@ -337,46 +343,24 @@ function safeEqualSecret(aRaw: string, bRaw: string): boolean { return timingSafeEqual(bufA, bufB); } -function getHostName(hostHeader?: string | string[]): string { - const host = (Array.isArray(hostHeader) ? hostHeader[0] : (hostHeader ?? "")) - .trim() - .toLowerCase(); - if (!host) { - return ""; - } - // Bracketed IPv6: [::1]:18789 - if (host.startsWith("[")) { - const end = host.indexOf("]"); - if (end !== -1) { - return host.slice(1, end); +function resolveAuthenticatedWebhookTargets( + targets: WebhookTarget[], + presentedToken: string, +): WebhookTarget[] { + const matches: WebhookTarget[] = []; + for (const target of targets) { + const token = target.account.config.password?.trim() ?? ""; + if (!token) { + continue; + } + if (safeEqualSecret(presentedToken, token)) { + matches.push(target); + if (matches.length > 1) { + break; + } } } - const [name] = host.split(":"); - return name ?? ""; -} - -function isDirectLocalLoopbackRequest(req: IncomingMessage): boolean { - const remote = (req.socket?.remoteAddress ?? "").trim().toLowerCase(); - const remoteIsLoopback = - remote === "127.0.0.1" || remote === "::1" || remote === "::ffff:127.0.0.1"; - if (!remoteIsLoopback) { - return false; - } - - const host = getHostName(req.headers?.host); - const hostIsLocal = host === "localhost" || host === "127.0.0.1" || host === "::1"; - if (!hostIsLocal) { - return false; - } - - // If a reverse proxy is in front, it will usually inject forwarding headers. - // Passwordless webhooks must never be accepted through a proxy. - const hasForwarded = Boolean( - req.headers?.["x-forwarded-for"] || - req.headers?.["x-real-ip"] || - req.headers?.["x-forwarded-host"], - ); - return !hasForwarded; + return matches; } export async function handleBlueBubblesWebhookRequest( @@ -466,29 +450,7 @@ export async function handleBlueBubblesWebhookRequest( req.headers["x-bluebubbles-guid"] ?? req.headers["authorization"]; const guid = (Array.isArray(headerToken) ? headerToken[0] : headerToken) ?? guidParam ?? ""; - - const strictMatches: WebhookTarget[] = []; - const passwordlessTargets: WebhookTarget[] = []; - for (const target of targets) { - const token = target.account.config.password?.trim() ?? ""; - if (!token) { - passwordlessTargets.push(target); - continue; - } - if (safeEqualSecret(guid, token)) { - strictMatches.push(target); - if (strictMatches.length > 1) { - break; - } - } - } - - const matching = - strictMatches.length > 0 - ? strictMatches - : isDirectLocalLoopbackRequest(req) - ? passwordlessTargets - : []; + const matching = resolveAuthenticatedWebhookTargets(targets, guid); if (matching.length === 0) { res.statusCode = 401;
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
6- github.com/openclaw/openclaw/commit/283029bdea23164ab7482b320cb420d1b90df806ghsapatchWEB
- github.com/openclaw/openclaw/commit/6b2f2811dc623e5faaf2f76afaa9279637174590ghsapatchWEB
- github.com/advisories/GHSA-5mx2-2mgw-x8rmghsaADVISORY
- github.com/openclaw/openclaw/security/advisories/GHSA-5mx2-2mgw-x8rmghsathird-party-advisoryWEB
- nvd.nist.gov/vuln/detail/CVE-2026-32896ghsaADVISORY
- www.vulncheck.com/advisories/openclaw-unauthenticated-webhook-access-via-passwordless-fallback-in-bluebubbles-pluginghsathird-party-advisoryWEB
News mentions
0No linked articles in our index yet.