mirror of
https://github.com/farcasclaudiu/openclaw.git
synced 2026-06-28 21:01:43 +03: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/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/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: 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.
|
- 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/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.
|
- 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>`).
|
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.
|
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)
|
## 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.
|
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,
|
body: unknown,
|
||||||
headers: Record<string, string> = {},
|
headers: Record<string, string> = {},
|
||||||
): IncomingMessage {
|
): IncomingMessage {
|
||||||
|
if (headers.host === undefined) {
|
||||||
|
headers.host = "localhost";
|
||||||
|
}
|
||||||
const parsedUrl = new URL(url, "http://localhost");
|
const parsedUrl = new URL(url, "http://localhost");
|
||||||
const hasAuthQuery = parsedUrl.searchParams.has("guid") || parsedUrl.searchParams.has("password");
|
const hasAuthQuery = parsedUrl.searchParams.has("guid") || parsedUrl.searchParams.has("password");
|
||||||
const hasAuthHeader =
|
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 () => {
|
it("ignores unregistered webhook paths", async () => {
|
||||||
const req = createMockRequest("POST", "/unregistered-path", {});
|
const req = createMockRequest("POST", "/unregistered-path", {});
|
||||||
const res = createMockResponse();
|
const res = createMockResponse();
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||||
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
||||||
|
import { timingSafeEqual } from "node:crypto";
|
||||||
import {
|
import {
|
||||||
normalizeWebhookMessage,
|
normalizeWebhookMessage,
|
||||||
normalizeWebhookReaction,
|
normalizeWebhookReaction,
|
||||||
@@ -315,6 +316,73 @@ function maskSecret(value: string): string {
|
|||||||
return `${value.slice(0, 2)}***${value.slice(-2)}`;
|
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(
|
export async function handleBlueBubblesWebhookRequest(
|
||||||
req: IncomingMessage,
|
req: IncomingMessage,
|
||||||
res: ServerResponse,
|
res: ServerResponse,
|
||||||
@@ -407,14 +475,14 @@ export async function handleBlueBubblesWebhookRequest(
|
|||||||
const guid = (Array.isArray(headerToken) ? headerToken[0] : headerToken) ?? guidParam ?? "";
|
const guid = (Array.isArray(headerToken) ? headerToken[0] : headerToken) ?? guidParam ?? "";
|
||||||
|
|
||||||
const strictMatches: WebhookTarget[] = [];
|
const strictMatches: WebhookTarget[] = [];
|
||||||
const fallbackTargets: WebhookTarget[] = [];
|
const passwordlessTargets: WebhookTarget[] = [];
|
||||||
for (const target of targets) {
|
for (const target of targets) {
|
||||||
const token = target.account.config.password?.trim() ?? "";
|
const token = target.account.config.password?.trim() ?? "";
|
||||||
if (!token) {
|
if (!token) {
|
||||||
fallbackTargets.push(target);
|
passwordlessTargets.push(target);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (guid && guid.trim() === token) {
|
if (safeEqualSecret(guid, token)) {
|
||||||
strictMatches.push(target);
|
strictMatches.push(target);
|
||||||
if (strictMatches.length > 1) {
|
if (strictMatches.length > 1) {
|
||||||
break;
|
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) {
|
if (matching.length === 0) {
|
||||||
res.statusCode = 401;
|
res.statusCode = 401;
|
||||||
|
|||||||
Reference in New Issue
Block a user