mirror of
https://github.com/openclaw/openclaw.git
synced 2026-02-19 18:39:20 -05:00
fix(security): harden BlueBubbles webhook auth behind proxies
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user