diff --git a/CHANGELOG.md b/CHANGELOG.md index d5f68b1a4..db6a9ac32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/channels/bluebubbles.md b/docs/channels/bluebubbles.md index c639cacec..f9dcff3b6 100644 --- a/docs/channels/bluebubbles.md +++ b/docs/channels/bluebubbles.md @@ -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=` or `x-password`), regardless of loopback/proxy topology. ## Keeping Messages.app alive (VM / headless setups) diff --git a/extensions/bluebubbles/src/monitor.test.ts b/extensions/bluebubbles/src/monitor.test.ts index 6e4d39cbb..7b4d95d76 100644 --- a/extensions/bluebubbles/src/monitor.test.ts +++ b/extensions/bluebubbles/src/monitor.test.ts @@ -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,77 +750,49 @@ 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", + unregister = registerBlueBubblesWebhookTarget({ + account, + config, + runtime: { log: vi.fn(), error: vi.fn() }, + core, + path: "/bluebubbles-webhook", + }); + + const headerVariants: Record[] = [ + { 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", + }, }, - }, - { "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); + 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 () => { diff --git a/extensions/bluebubbles/src/monitor.ts b/extensions/bluebubbles/src/monitor.ts index 9b5bd2409..367f095b8 100644 --- a/extensions/bluebubbles/src/monitor.ts +++ b/extensions/bluebubbles/src/monitor.ts @@ -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;