OpenClaw has BlueBubbles webhook auth bypass via loopback proxy trust
Description
OpenClaw is a personal AI assistant. Prior to 2026.2.13, the optional BlueBubbles iMessage channel plugin could accept webhook requests as authenticated based only on the TCP peer address being loopback (127.0.0.1, ::1, ::ffff:127.0.0.1) even when the configured webhook secret was missing or incorrect. This does not affect the default iMessage integration unless BlueBubbles is installed and enabled. Version 2026.2.13 contains a patch. Other mitigations include setting a non-empty BlueBubbles webhook password and avoiding deployments where a public-facing reverse proxy forwards to a loopback-bound Gateway without strong upstream authentication.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
openclawnpm | < 2026.2.13 | 2026.2.13 |
@openclaw/bluebubblesnpm | < 2026.2.13 | 2026.2.13 |
Affected products
2- openclaw/@openclaw/bluebubblesv5Range: < 2026.2.13
Patches
2743f4b28495cfix(security): harden BlueBubbles webhook auth behind proxies
4 files changed · +158 −4
CHANGELOG.md+1 −0 modified@@ -42,6 +42,7 @@ Docs: https://docs.openclaw.ai - Security/Gateway: stop returning raw resolved config values in `skills.status` requirement checks (prevents operator.read clients from reading secrets). Thanks @simecek. - Security/Zalo: reject ambiguous shared-path webhook routing when multiple webhook targets match the same secret. - Security/BlueBubbles: reject ambiguous shared-path webhook routing when multiple webhook targets match the same guid/password. +- Security/BlueBubbles: harden BlueBubbles webhook auth behind reverse proxies by only accepting passwordless webhooks for direct localhost loopback requests (forwarded/proxied requests now require a password). Thanks @simecek. - Security/BlueBubbles: require explicit `mediaLocalRoots` allowlists for local outbound media path reads to prevent local file disclosure. (#16322) Thanks @mbelinky. - Cron/Slack: preserve agent identity (name and icon) when cron jobs deliver outbound messages. (#16242) Thanks @robbyczgw-cla. - Cron: deliver text-only output directly when `delivery.to` is set so cron recipients get full output instead of summaries. (#16360) Thanks @rubyrunsstuff.
docs/channels/bluebubbles.md+4 −0 modified@@ -44,6 +44,10 @@ Status: bundled plugin that talks to the BlueBubbles macOS server over HTTP. **R 4. Point BlueBubbles webhooks to your gateway (example: `https://your-gateway-host:3000/bluebubbles-webhook?password=<password>`). 5. Start the gateway; it will register the webhook handler and start pairing. +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. + ## Keeping Messages.app alive (VM / headless setups) Some macOS VM / always-on setups can end up with Messages.app going “idle” (incoming events stop until the app is opened/foregrounded). A simple workaround is to **poke Messages every 5 minutes** using an AppleScript + LaunchAgent.
extensions/bluebubbles/src/monitor.test.ts+76 −0 modified@@ -256,6 +256,9 @@ function createMockRequest( body: unknown, headers: Record<string, string> = {}, ): IncomingMessage { + if (headers.host === undefined) { + headers.host = "localhost"; + } const parsedUrl = new URL(url, "http://localhost"); const hasAuthQuery = parsedUrl.searchParams.has("guid") || parsedUrl.searchParams.has("password"); const hasAuthHeader = @@ -704,6 +707,79 @@ describe("BlueBubbles webhook monitor", () => { } }); + it("rejects passwordless targets when the request looks proxied (has 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", + }, + }, + { "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, + 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(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); + }); + it("ignores unregistered webhook paths", async () => { const req = createMockRequest("POST", "/unregistered-path", {}); const res = createMockResponse();
extensions/bluebubbles/src/monitor.ts+77 −4 modified@@ -1,5 +1,6 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import type { OpenClawConfig } from "openclaw/plugin-sdk"; +import { timingSafeEqual } from "node:crypto"; import { normalizeWebhookMessage, normalizeWebhookReaction, @@ -315,6 +316,73 @@ function maskSecret(value: string): string { 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 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); + } + } + 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; +} + export async function handleBlueBubblesWebhookRequest( req: IncomingMessage, res: ServerResponse, @@ -407,22 +475,27 @@ export async function handleBlueBubblesWebhookRequest( const guid = (Array.isArray(headerToken) ? headerToken[0] : headerToken) ?? guidParam ?? ""; const strictMatches: WebhookTarget[] = []; - const fallbackTargets: WebhookTarget[] = []; + const passwordlessTargets: WebhookTarget[] = []; for (const target of targets) { const token = target.account.config.password?.trim() ?? ""; if (!token) { - fallbackTargets.push(target); + passwordlessTargets.push(target); continue; } - if (guid && guid.trim() === token) { + if (safeEqualSecret(guid, token)) { strictMatches.push(target); if (strictMatches.length > 1) { break; } } } - const matching = strictMatches.length > 0 ? strictMatches : fallbackTargets; + const matching = + strictMatches.length > 0 + ? strictMatches + : isDirectLocalLoopbackRequest(req) + ? passwordlessTargets + : []; if (matching.length === 0) { res.statusCode = 401;
f836c385ffc7fix: BlueBubbles webhook auth bypass via loopback proxy trust (#13787)
2 files changed · +40 −32
extensions/bluebubbles/src/monitor.test.ts+40 −28 modified@@ -254,9 +254,20 @@ function createMockRequest( body: unknown, headers: Record<string, string> = {}, ): IncomingMessage { + const parsedUrl = new URL(url, "http://localhost"); + const hasAuthQuery = parsedUrl.searchParams.has("guid") || parsedUrl.searchParams.has("password"); + const hasAuthHeader = + headers["x-guid"] !== undefined || + headers["x-password"] !== undefined || + headers["x-bluebubbles-guid"] !== undefined || + headers.authorization !== undefined; + if (!hasAuthQuery && !hasAuthHeader) { + parsedUrl.searchParams.set("password", "test-password"); + } + const req = new EventEmitter() as IncomingMessage; req.method = method; - req.url = url; + req.url = `${parsedUrl.pathname}${parsedUrl.search}`; req.headers = headers; (req as unknown as { socket: { remoteAddress: string } }).socket = { remoteAddress: "127.0.0.1" }; @@ -546,40 +557,41 @@ describe("BlueBubbles webhook monitor", () => { expect(res.statusCode).toBe(401); }); - it("allows localhost requests without authentication", async () => { + it("requires authentication for loopback requests when password is configured", async () => { const account = createMockAccount({ password: "secret-token" }); const config: OpenClawConfig = {}; const core = createMockRuntime(); setBlueBubblesRuntime(core); + for (const remoteAddress of ["127.0.0.1", "::1", "::ffff:127.0.0.1"]) { + 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, + }; - const req = createMockRequest("POST", "/bluebubbles-webhook", { - type: "new-message", - data: { - text: "hello", - handle: { address: "+15551234567" }, - isGroup: false, - isFromMe: false, - guid: "msg-1", - }, - }); - // Localhost address - (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 loopbackUnregister = registerBlueBubblesWebhookTarget({ + account, + config, + runtime: { log: vi.fn(), error: vi.fn() }, + core, + path: "/bluebubbles-webhook", + }); - const res = createMockResponse(); - const handled = await handleBlueBubblesWebhookRequest(req, res); + const res = createMockResponse(); + const handled = await handleBlueBubblesWebhookRequest(req, res); + expect(handled).toBe(true); + expect(res.statusCode).toBe(401); - expect(handled).toBe(true); - expect(res.statusCode).toBe(200); + loopbackUnregister(); + } }); it("ignores unregistered webhook paths", async () => {
extensions/bluebubbles/src/monitor.ts+0 −4 modified@@ -1533,10 +1533,6 @@ export async function handleBlueBubblesWebhookRequest( if (guid && guid.trim() === token) { return true; } - const remote = req.socket?.remoteAddress ?? ""; - if (remote === "127.0.0.1" || remote === "::1" || remote === "::ffff:127.0.0.1") { - return true; - } return false; });
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
7- github.com/advisories/GHSA-pchc-86f6-8758ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-26316ghsaADVISORY
- github.com/openclaw/openclaw/commit/743f4b28495cdeb0d5bf76f6ebf4af01f6a02e5aghsax_refsource_MISCWEB
- github.com/openclaw/openclaw/commit/f836c385ffc746cb954e8ee409f99d079bfdcd2fghsax_refsource_MISCWEB
- github.com/openclaw/openclaw/releases/tag/v2026.2.12ghsaWEB
- github.com/openclaw/openclaw/releases/tag/v2026.2.13ghsax_refsource_MISCWEB
- github.com/openclaw/openclaw/security/advisories/GHSA-pchc-86f6-8758ghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.