fix(security): harden BlueBubbles webhook auth behind proxies

This commit is contained in:
Peter Steinberger
2026-02-14 19:47:46 +01:00
parent b1dd23f61d
commit 743f4b2849
4 changed files with 158 additions and 4 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -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();

View File

@@ -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;