OpenClaw < 2026.2.12 - Webhook Authentication Bypass via Loopback remoteAddress Trust
Description
OpenClaw versions prior to 2026.2.12 contain a vulnerability in the BlueBubbles (optional plugin) webhook handler in which it authenticates requests based solely on loopback remoteAddress without validating forwarding headers, allowing bypass of configured webhook passwords. When the gateway operates behind a reverse proxy, unauthenticated remote attackers can inject arbitrary BlueBubbles message and reaction events by reaching the proxy endpoint.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
openclawnpm | < 2026.2.12 | 2026.2.12 |
Affected products
1Patches
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/openclaw/openclaw/commit/743f4b28495cdeb0d5bf76f6ebf4af01f6a02e5aghsapatchWEB
- github.com/openclaw/openclaw/commit/f836c385ffc746cb954e8ee409f99d079bfdcd2fghsapatchWEB
- github.com/advisories/GHSA-xc7w-v5x6-cc87ghsaADVISORY
- github.com/openclaw/openclaw/security/advisories/GHSA-xc7w-v5x6-cc87ghsavendor-advisoryWEB
- nvd.nist.gov/vuln/detail/CVE-2026-29613ghsaADVISORY
- www.vulncheck.com/advisories/openclaw-webhook-authentication-bypass-via-loopback-remoteaddress-trustghsathird-party-advisoryWEB
- github.com/openclaw/openclaw/releases/tag/v2026.2.12ghsaWEB
News mentions
0No linked articles in our index yet.