mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
fix(security): require BlueBubbles webhook auth
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user