diff --git a/CHANGELOG.md b/CHANGELOG.md index 7907774478..0383cfed5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/channels/bluebubbles.md b/docs/channels/bluebubbles.md index a63d2f1d35..fd677a1d58 100644 --- a/docs/channels/bluebubbles.md +++ b/docs/channels/bluebubbles.md @@ -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=`). 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. diff --git a/extensions/bluebubbles/src/monitor.test.ts b/extensions/bluebubbles/src/monitor.test.ts index 60afacf36e..6b1bfa9f1d 100644 --- a/extensions/bluebubbles/src/monitor.test.ts +++ b/extensions/bluebubbles/src/monitor.test.ts @@ -256,6 +256,9 @@ function createMockRequest( body: unknown, headers: Record = {}, ): 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(); diff --git a/extensions/bluebubbles/src/monitor.ts b/extensions/bluebubbles/src/monitor.ts index 0c565d44f8..1ff5896b5a 100644 --- a/extensions/bluebubbles/src/monitor.ts +++ b/extensions/bluebubbles/src/monitor.ts @@ -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,14 +475,14 @@ 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; @@ -422,7 +490,12 @@ export async function handleBlueBubblesWebhookRequest( } } - const matching = strictMatches.length > 0 ? strictMatches : fallbackTargets; + const matching = + strictMatches.length > 0 + ? strictMatches + : isDirectLocalLoopbackRequest(req) + ? passwordlessTargets + : []; if (matching.length === 0) { res.statusCode = 401;