fix(security): require BlueBubbles webhook auth

This commit is contained in:
Peter Steinberger
2026-02-21 11:41:35 +01:00
parent 220bd95eff
commit 6b2f2811dc
4 changed files with 71 additions and 135 deletions

View File

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

View File

@@ -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=<password>` or `x-password`), regardless of loopback/proxy topology.
## Keeping Messages.app alive (VM / headless setups)

View File

@@ -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<string, string>[] = [
{ 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 () => {

View File

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