fix: BlueBubbles webhook auth bypass via loopback proxy trust (#13787)

* fix(an-08): apply security fix

Generated by staged fix workflow.

* fix(an-08): apply security fix

Generated by staged fix workflow.

* fix(an-08): stabilize bluebubbles auth fixture for security patch

Restore the default test password in createMockAccount and add a
fallback password query in createMockRequest when auth is omitted.

This keeps the AN-08 loopback-auth regression tests strict while
preserving existing monitor behavior tests that assume authenticated
webhook fixtures.
This commit is contained in:
Coy Geek
2026-02-12 05:12:17 -08:00
committed by GitHub
parent 8dd60fc7d9
commit f836c385ff
2 changed files with 40 additions and 32 deletions

View File

@@ -254,9 +254,20 @@ function createMockRequest(
body: unknown,
headers: Record<string, string> = {},
): IncomingMessage {
const parsedUrl = new URL(url, "http://localhost");
const hasAuthQuery = parsedUrl.searchParams.has("guid") || parsedUrl.searchParams.has("password");
const hasAuthHeader =
headers["x-guid"] !== undefined ||
headers["x-password"] !== undefined ||
headers["x-bluebubbles-guid"] !== undefined ||
headers.authorization !== undefined;
if (!hasAuthQuery && !hasAuthHeader) {
parsedUrl.searchParams.set("password", "test-password");
}
const req = new EventEmitter() as IncomingMessage;
req.method = method;
req.url = url;
req.url = `${parsedUrl.pathname}${parsedUrl.search}`;
req.headers = headers;
(req as unknown as { socket: { remoteAddress: string } }).socket = { remoteAddress: "127.0.0.1" };
@@ -546,40 +557,41 @@ describe("BlueBubbles webhook monitor", () => {
expect(res.statusCode).toBe(401);
});
it("allows localhost requests without authentication", async () => {
it("requires authentication for loopback requests when password is configured", async () => {
const account = createMockAccount({ password: "secret-token" });
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
for (const remoteAddress of ["127.0.0.1", "::1", "::ffff:127.0.0.1"]) {
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,
};
const req = createMockRequest("POST", "/bluebubbles-webhook", {
type: "new-message",
data: {
text: "hello",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-1",
},
});
// Localhost address
(req as unknown as { socket: { remoteAddress: string } }).socket = {
remoteAddress: "127.0.0.1",
};
const loopbackUnregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
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);
const res = createMockResponse();
const handled = await handleBlueBubblesWebhookRequest(req, res);
expect(handled).toBe(true);
expect(res.statusCode).toBe(200);
loopbackUnregister();
}
});
it("ignores unregistered webhook paths", async () => {

View File

@@ -1533,10 +1533,6 @@ export async function handleBlueBubblesWebhookRequest(
if (guid && guid.trim() === token) {
return true;
}
const remote = req.socket?.remoteAddress ?? "";
if (remote === "127.0.0.1" || remote === "::1" || remote === "::ffff:127.0.0.1") {
return true;
}
return false;
});