@openclaw/bluebubbles 2026.2.19 → 2026.2.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/config-schema.test.ts +55 -0
- package/src/config-schema.ts +33 -21
- package/src/monitor.test.ts +77 -66
- package/src/monitor.ts +64 -130
package/package.json
CHANGED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { BlueBubblesConfigSchema } from "./config-schema.js";
|
|
3
|
+
|
|
4
|
+
describe("BlueBubblesConfigSchema", () => {
|
|
5
|
+
it("accepts account config when serverUrl and password are both set", () => {
|
|
6
|
+
const parsed = BlueBubblesConfigSchema.safeParse({
|
|
7
|
+
serverUrl: "http://localhost:1234",
|
|
8
|
+
password: "secret",
|
|
9
|
+
});
|
|
10
|
+
expect(parsed.success).toBe(true);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("requires password when top-level serverUrl is configured", () => {
|
|
14
|
+
const parsed = BlueBubblesConfigSchema.safeParse({
|
|
15
|
+
serverUrl: "http://localhost:1234",
|
|
16
|
+
});
|
|
17
|
+
expect(parsed.success).toBe(false);
|
|
18
|
+
if (parsed.success) {
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
expect(parsed.error.issues[0]?.path).toEqual(["password"]);
|
|
22
|
+
expect(parsed.error.issues[0]?.message).toBe(
|
|
23
|
+
"password is required when serverUrl is configured",
|
|
24
|
+
);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("requires password when account serverUrl is configured", () => {
|
|
28
|
+
const parsed = BlueBubblesConfigSchema.safeParse({
|
|
29
|
+
accounts: {
|
|
30
|
+
work: {
|
|
31
|
+
serverUrl: "http://localhost:1234",
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
expect(parsed.success).toBe(false);
|
|
36
|
+
if (parsed.success) {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
expect(parsed.error.issues[0]?.path).toEqual(["accounts", "work", "password"]);
|
|
40
|
+
expect(parsed.error.issues[0]?.message).toBe(
|
|
41
|
+
"password is required when serverUrl is configured",
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("allows password omission when serverUrl is not configured", () => {
|
|
46
|
+
const parsed = BlueBubblesConfigSchema.safeParse({
|
|
47
|
+
accounts: {
|
|
48
|
+
work: {
|
|
49
|
+
name: "Work iMessage",
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
expect(parsed.success).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
});
|
package/src/config-schema.ts
CHANGED
|
@@ -24,27 +24,39 @@ const bluebubblesGroupConfigSchema = z.object({
|
|
|
24
24
|
tools: ToolPolicySchema,
|
|
25
25
|
});
|
|
26
26
|
|
|
27
|
-
const bluebubblesAccountSchema = z
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
})
|
|
27
|
+
const bluebubblesAccountSchema = z
|
|
28
|
+
.object({
|
|
29
|
+
name: z.string().optional(),
|
|
30
|
+
enabled: z.boolean().optional(),
|
|
31
|
+
markdown: MarkdownConfigSchema,
|
|
32
|
+
serverUrl: z.string().optional(),
|
|
33
|
+
password: z.string().optional(),
|
|
34
|
+
webhookPath: z.string().optional(),
|
|
35
|
+
dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(),
|
|
36
|
+
allowFrom: z.array(allowFromEntry).optional(),
|
|
37
|
+
groupAllowFrom: z.array(allowFromEntry).optional(),
|
|
38
|
+
groupPolicy: z.enum(["open", "disabled", "allowlist"]).optional(),
|
|
39
|
+
historyLimit: z.number().int().min(0).optional(),
|
|
40
|
+
dmHistoryLimit: z.number().int().min(0).optional(),
|
|
41
|
+
textChunkLimit: z.number().int().positive().optional(),
|
|
42
|
+
chunkMode: z.enum(["length", "newline"]).optional(),
|
|
43
|
+
mediaMaxMb: z.number().int().positive().optional(),
|
|
44
|
+
mediaLocalRoots: z.array(z.string()).optional(),
|
|
45
|
+
sendReadReceipts: z.boolean().optional(),
|
|
46
|
+
blockStreaming: z.boolean().optional(),
|
|
47
|
+
groups: z.object({}).catchall(bluebubblesGroupConfigSchema).optional(),
|
|
48
|
+
})
|
|
49
|
+
.superRefine((value, ctx) => {
|
|
50
|
+
const serverUrl = value.serverUrl?.trim() ?? "";
|
|
51
|
+
const password = value.password?.trim() ?? "";
|
|
52
|
+
if (serverUrl && !password) {
|
|
53
|
+
ctx.addIssue({
|
|
54
|
+
code: z.ZodIssueCode.custom,
|
|
55
|
+
path: ["password"],
|
|
56
|
+
message: "password is required when serverUrl is configured",
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
});
|
|
48
60
|
|
|
49
61
|
export const BlueBubblesConfigSchema = bluebubblesAccountSchema.extend({
|
|
50
62
|
accounts: z.object({}).catchall(bluebubblesAccountSchema).optional(),
|
package/src/monitor.test.ts
CHANGED
|
@@ -452,6 +452,45 @@ describe("BlueBubbles webhook monitor", () => {
|
|
|
452
452
|
expect(res.statusCode).toBe(400);
|
|
453
453
|
});
|
|
454
454
|
|
|
455
|
+
it("accepts URL-encoded payload wrappers", async () => {
|
|
456
|
+
const account = createMockAccount();
|
|
457
|
+
const config: OpenClawConfig = {};
|
|
458
|
+
const core = createMockRuntime();
|
|
459
|
+
setBlueBubblesRuntime(core);
|
|
460
|
+
|
|
461
|
+
unregister = registerBlueBubblesWebhookTarget({
|
|
462
|
+
account,
|
|
463
|
+
config,
|
|
464
|
+
runtime: { log: vi.fn(), error: vi.fn() },
|
|
465
|
+
core,
|
|
466
|
+
path: "/bluebubbles-webhook",
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
const payload = {
|
|
470
|
+
type: "new-message",
|
|
471
|
+
data: {
|
|
472
|
+
text: "hello",
|
|
473
|
+
handle: { address: "+15551234567" },
|
|
474
|
+
isGroup: false,
|
|
475
|
+
isFromMe: false,
|
|
476
|
+
guid: "msg-1",
|
|
477
|
+
date: Date.now(),
|
|
478
|
+
},
|
|
479
|
+
};
|
|
480
|
+
const encodedBody = new URLSearchParams({
|
|
481
|
+
payload: JSON.stringify(payload),
|
|
482
|
+
}).toString();
|
|
483
|
+
|
|
484
|
+
const req = createMockRequest("POST", "/bluebubbles-webhook", encodedBody);
|
|
485
|
+
const res = createMockResponse();
|
|
486
|
+
|
|
487
|
+
const handled = await handleBlueBubblesWebhookRequest(req, res);
|
|
488
|
+
|
|
489
|
+
expect(handled).toBe(true);
|
|
490
|
+
expect(res.statusCode).toBe(200);
|
|
491
|
+
expect(res.body).toBe("ok");
|
|
492
|
+
});
|
|
493
|
+
|
|
455
494
|
it("returns 408 when request body times out (Slow-Loris protection)", async () => {
|
|
456
495
|
vi.useFakeTimers();
|
|
457
496
|
try {
|
|
@@ -659,15 +698,15 @@ describe("BlueBubbles webhook monitor", () => {
|
|
|
659
698
|
expect(sinkB).not.toHaveBeenCalled();
|
|
660
699
|
});
|
|
661
700
|
|
|
662
|
-
it("
|
|
701
|
+
it("ignores targets without passwords when a password-authenticated target matches", async () => {
|
|
663
702
|
const accountStrict = createMockAccount({ password: "secret-token" });
|
|
664
|
-
const
|
|
703
|
+
const accountWithoutPassword = createMockAccount({ password: undefined });
|
|
665
704
|
const config: OpenClawConfig = {};
|
|
666
705
|
const core = createMockRuntime();
|
|
667
706
|
setBlueBubblesRuntime(core);
|
|
668
707
|
|
|
669
708
|
const sinkStrict = vi.fn();
|
|
670
|
-
const
|
|
709
|
+
const sinkWithoutPassword = vi.fn();
|
|
671
710
|
|
|
672
711
|
const req = createMockRequest("POST", "/bluebubbles-webhook?password=secret-token", {
|
|
673
712
|
type: "new-message",
|
|
@@ -691,17 +730,17 @@ describe("BlueBubbles webhook monitor", () => {
|
|
|
691
730
|
path: "/bluebubbles-webhook",
|
|
692
731
|
statusSink: sinkStrict,
|
|
693
732
|
});
|
|
694
|
-
const
|
|
695
|
-
account:
|
|
733
|
+
const unregisterNoPassword = registerBlueBubblesWebhookTarget({
|
|
734
|
+
account: accountWithoutPassword,
|
|
696
735
|
config,
|
|
697
736
|
runtime: { log: vi.fn(), error: vi.fn() },
|
|
698
737
|
core,
|
|
699
738
|
path: "/bluebubbles-webhook",
|
|
700
|
-
statusSink:
|
|
739
|
+
statusSink: sinkWithoutPassword,
|
|
701
740
|
});
|
|
702
741
|
unregister = () => {
|
|
703
742
|
unregisterStrict();
|
|
704
|
-
|
|
743
|
+
unregisterNoPassword();
|
|
705
744
|
};
|
|
706
745
|
|
|
707
746
|
const res = createMockResponse();
|
|
@@ -710,7 +749,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|
|
710
749
|
expect(handled).toBe(true);
|
|
711
750
|
expect(res.statusCode).toBe(200);
|
|
712
751
|
expect(sinkStrict).toHaveBeenCalledTimes(1);
|
|
713
|
-
expect(
|
|
752
|
+
expect(sinkWithoutPassword).not.toHaveBeenCalled();
|
|
714
753
|
});
|
|
715
754
|
|
|
716
755
|
it("requires authentication for loopback requests when password is configured", async () => {
|
|
@@ -750,65 +789,12 @@ describe("BlueBubbles webhook monitor", () => {
|
|
|
750
789
|
}
|
|
751
790
|
});
|
|
752
791
|
|
|
753
|
-
it("rejects
|
|
792
|
+
it("rejects targets without passwords for loopback and proxied-looking requests", async () => {
|
|
754
793
|
const account = createMockAccount({ password: undefined });
|
|
755
794
|
const config: OpenClawConfig = {};
|
|
756
795
|
const core = createMockRuntime();
|
|
757
796
|
setBlueBubblesRuntime(core);
|
|
758
797
|
|
|
759
|
-
const req = createMockRequest(
|
|
760
|
-
"POST",
|
|
761
|
-
"/bluebubbles-webhook",
|
|
762
|
-
{
|
|
763
|
-
type: "new-message",
|
|
764
|
-
data: {
|
|
765
|
-
text: "hello",
|
|
766
|
-
handle: { address: "+15551234567" },
|
|
767
|
-
isGroup: false,
|
|
768
|
-
isFromMe: false,
|
|
769
|
-
guid: "msg-1",
|
|
770
|
-
},
|
|
771
|
-
},
|
|
772
|
-
{ "x-forwarded-for": "203.0.113.10", host: "localhost" },
|
|
773
|
-
);
|
|
774
|
-
(req as unknown as { socket: { remoteAddress: string } }).socket = {
|
|
775
|
-
remoteAddress: "127.0.0.1",
|
|
776
|
-
};
|
|
777
|
-
|
|
778
|
-
unregister = registerBlueBubblesWebhookTarget({
|
|
779
|
-
account,
|
|
780
|
-
config,
|
|
781
|
-
runtime: { log: vi.fn(), error: vi.fn() },
|
|
782
|
-
core,
|
|
783
|
-
path: "/bluebubbles-webhook",
|
|
784
|
-
});
|
|
785
|
-
|
|
786
|
-
const res = createMockResponse();
|
|
787
|
-
const handled = await handleBlueBubblesWebhookRequest(req, res);
|
|
788
|
-
expect(handled).toBe(true);
|
|
789
|
-
expect(res.statusCode).toBe(401);
|
|
790
|
-
});
|
|
791
|
-
|
|
792
|
-
it("accepts passwordless targets for direct localhost loopback requests (no forwarding headers)", async () => {
|
|
793
|
-
const account = createMockAccount({ password: undefined });
|
|
794
|
-
const config: OpenClawConfig = {};
|
|
795
|
-
const core = createMockRuntime();
|
|
796
|
-
setBlueBubblesRuntime(core);
|
|
797
|
-
|
|
798
|
-
const req = createMockRequest("POST", "/bluebubbles-webhook", {
|
|
799
|
-
type: "new-message",
|
|
800
|
-
data: {
|
|
801
|
-
text: "hello",
|
|
802
|
-
handle: { address: "+15551234567" },
|
|
803
|
-
isGroup: false,
|
|
804
|
-
isFromMe: false,
|
|
805
|
-
guid: "msg-1",
|
|
806
|
-
},
|
|
807
|
-
});
|
|
808
|
-
(req as unknown as { socket: { remoteAddress: string } }).socket = {
|
|
809
|
-
remoteAddress: "127.0.0.1",
|
|
810
|
-
};
|
|
811
|
-
|
|
812
798
|
unregister = registerBlueBubblesWebhookTarget({
|
|
813
799
|
account,
|
|
814
800
|
config,
|
|
@@ -817,10 +803,35 @@ describe("BlueBubbles webhook monitor", () => {
|
|
|
817
803
|
path: "/bluebubbles-webhook",
|
|
818
804
|
});
|
|
819
805
|
|
|
820
|
-
const
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
806
|
+
const headerVariants: Record<string, string>[] = [
|
|
807
|
+
{ host: "localhost" },
|
|
808
|
+
{ host: "localhost", "x-forwarded-for": "203.0.113.10" },
|
|
809
|
+
{ host: "localhost", forwarded: "for=203.0.113.10;proto=https;host=example.com" },
|
|
810
|
+
];
|
|
811
|
+
for (const headers of headerVariants) {
|
|
812
|
+
const req = createMockRequest(
|
|
813
|
+
"POST",
|
|
814
|
+
"/bluebubbles-webhook",
|
|
815
|
+
{
|
|
816
|
+
type: "new-message",
|
|
817
|
+
data: {
|
|
818
|
+
text: "hello",
|
|
819
|
+
handle: { address: "+15551234567" },
|
|
820
|
+
isGroup: false,
|
|
821
|
+
isFromMe: false,
|
|
822
|
+
guid: "msg-1",
|
|
823
|
+
},
|
|
824
|
+
},
|
|
825
|
+
headers,
|
|
826
|
+
);
|
|
827
|
+
(req as unknown as { socket: { remoteAddress: string } }).socket = {
|
|
828
|
+
remoteAddress: "127.0.0.1",
|
|
829
|
+
};
|
|
830
|
+
const res = createMockResponse();
|
|
831
|
+
const handled = await handleBlueBubblesWebhookRequest(req, res);
|
|
832
|
+
expect(handled).toBe(true);
|
|
833
|
+
expect(res.statusCode).toBe(401);
|
|
834
|
+
}
|
|
824
835
|
});
|
|
825
836
|
|
|
826
837
|
it("ignores unregistered webhook paths", async () => {
|
package/src/monitor.ts
CHANGED
|
@@ -2,8 +2,12 @@ import { timingSafeEqual } from "node:crypto";
|
|
|
2
2
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
3
3
|
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
4
4
|
import {
|
|
5
|
+
isRequestBodyLimitError,
|
|
6
|
+
readRequestBodyWithLimit,
|
|
5
7
|
registerWebhookTarget,
|
|
6
8
|
rejectNonPostWebhookRequest,
|
|
9
|
+
requestBodyErrorToText,
|
|
10
|
+
resolveSingleWebhookTarget,
|
|
7
11
|
resolveWebhookTargets,
|
|
8
12
|
} from "openclaw/plugin-sdk";
|
|
9
13
|
import {
|
|
@@ -239,64 +243,61 @@ export function registerBlueBubblesWebhookTarget(target: WebhookTarget): () => v
|
|
|
239
243
|
};
|
|
240
244
|
}
|
|
241
245
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
return await new Promise<{ ok: boolean; value?: unknown; error?: string }>((resolve) => {
|
|
246
|
-
let done = false;
|
|
247
|
-
const finish = (result: { ok: boolean; value?: unknown; error?: string }) => {
|
|
248
|
-
if (done) {
|
|
249
|
-
return;
|
|
250
|
-
}
|
|
251
|
-
done = true;
|
|
252
|
-
clearTimeout(timer);
|
|
253
|
-
resolve(result);
|
|
254
|
-
};
|
|
246
|
+
type ReadBlueBubblesWebhookBodyResult =
|
|
247
|
+
| { ok: true; value: unknown }
|
|
248
|
+
| { ok: false; statusCode: number; error: string };
|
|
255
249
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
250
|
+
function parseBlueBubblesWebhookPayload(
|
|
251
|
+
rawBody: string,
|
|
252
|
+
): { ok: true; value: unknown } | { ok: false; error: string } {
|
|
253
|
+
const trimmed = rawBody.trim();
|
|
254
|
+
if (!trimmed) {
|
|
255
|
+
return { ok: false, error: "empty payload" };
|
|
256
|
+
}
|
|
257
|
+
try {
|
|
258
|
+
return { ok: true, value: JSON.parse(trimmed) as unknown };
|
|
259
|
+
} catch {
|
|
260
|
+
const params = new URLSearchParams(rawBody);
|
|
261
|
+
const payload = params.get("payload") ?? params.get("data") ?? params.get("message");
|
|
262
|
+
if (!payload) {
|
|
263
|
+
return { ok: false, error: "invalid json" };
|
|
264
|
+
}
|
|
265
|
+
try {
|
|
266
|
+
return { ok: true, value: JSON.parse(payload) as unknown };
|
|
267
|
+
} catch (error) {
|
|
268
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
260
272
|
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
});
|
|
270
|
-
req.on("end", () => {
|
|
271
|
-
try {
|
|
272
|
-
const raw = Buffer.concat(chunks).toString("utf8");
|
|
273
|
-
if (!raw.trim()) {
|
|
274
|
-
finish({ ok: false, error: "empty payload" });
|
|
275
|
-
return;
|
|
276
|
-
}
|
|
277
|
-
try {
|
|
278
|
-
finish({ ok: true, value: JSON.parse(raw) as unknown });
|
|
279
|
-
return;
|
|
280
|
-
} catch {
|
|
281
|
-
const params = new URLSearchParams(raw);
|
|
282
|
-
const payload = params.get("payload") ?? params.get("data") ?? params.get("message");
|
|
283
|
-
if (payload) {
|
|
284
|
-
finish({ ok: true, value: JSON.parse(payload) as unknown });
|
|
285
|
-
return;
|
|
286
|
-
}
|
|
287
|
-
throw new Error("invalid json");
|
|
288
|
-
}
|
|
289
|
-
} catch (err) {
|
|
290
|
-
finish({ ok: false, error: err instanceof Error ? err.message : String(err) });
|
|
291
|
-
}
|
|
292
|
-
});
|
|
293
|
-
req.on("error", (err) => {
|
|
294
|
-
finish({ ok: false, error: err instanceof Error ? err.message : String(err) });
|
|
295
|
-
});
|
|
296
|
-
req.on("close", () => {
|
|
297
|
-
finish({ ok: false, error: "connection closed" });
|
|
273
|
+
async function readBlueBubblesWebhookBody(
|
|
274
|
+
req: IncomingMessage,
|
|
275
|
+
maxBytes: number,
|
|
276
|
+
): Promise<ReadBlueBubblesWebhookBodyResult> {
|
|
277
|
+
try {
|
|
278
|
+
const rawBody = await readRequestBodyWithLimit(req, {
|
|
279
|
+
maxBytes,
|
|
280
|
+
timeoutMs: 30_000,
|
|
298
281
|
});
|
|
299
|
-
|
|
282
|
+
const parsed = parseBlueBubblesWebhookPayload(rawBody);
|
|
283
|
+
if (!parsed.ok) {
|
|
284
|
+
return { ok: false, statusCode: 400, error: parsed.error };
|
|
285
|
+
}
|
|
286
|
+
return parsed;
|
|
287
|
+
} catch (error) {
|
|
288
|
+
if (isRequestBodyLimitError(error)) {
|
|
289
|
+
return {
|
|
290
|
+
ok: false,
|
|
291
|
+
statusCode: error.statusCode,
|
|
292
|
+
error: requestBodyErrorToText(error.code),
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
return {
|
|
296
|
+
ok: false,
|
|
297
|
+
statusCode: 400,
|
|
298
|
+
error: error instanceof Error ? error.message : String(error),
|
|
299
|
+
};
|
|
300
|
+
}
|
|
300
301
|
}
|
|
301
302
|
|
|
302
303
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
@@ -337,48 +338,6 @@ function safeEqualSecret(aRaw: string, bRaw: string): boolean {
|
|
|
337
338
|
return timingSafeEqual(bufA, bufB);
|
|
338
339
|
}
|
|
339
340
|
|
|
340
|
-
function getHostName(hostHeader?: string | string[]): string {
|
|
341
|
-
const host = (Array.isArray(hostHeader) ? hostHeader[0] : (hostHeader ?? ""))
|
|
342
|
-
.trim()
|
|
343
|
-
.toLowerCase();
|
|
344
|
-
if (!host) {
|
|
345
|
-
return "";
|
|
346
|
-
}
|
|
347
|
-
// Bracketed IPv6: [::1]:18789
|
|
348
|
-
if (host.startsWith("[")) {
|
|
349
|
-
const end = host.indexOf("]");
|
|
350
|
-
if (end !== -1) {
|
|
351
|
-
return host.slice(1, end);
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
const [name] = host.split(":");
|
|
355
|
-
return name ?? "";
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
function isDirectLocalLoopbackRequest(req: IncomingMessage): boolean {
|
|
359
|
-
const remote = (req.socket?.remoteAddress ?? "").trim().toLowerCase();
|
|
360
|
-
const remoteIsLoopback =
|
|
361
|
-
remote === "127.0.0.1" || remote === "::1" || remote === "::ffff:127.0.0.1";
|
|
362
|
-
if (!remoteIsLoopback) {
|
|
363
|
-
return false;
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
const host = getHostName(req.headers?.host);
|
|
367
|
-
const hostIsLocal = host === "localhost" || host === "127.0.0.1" || host === "::1";
|
|
368
|
-
if (!hostIsLocal) {
|
|
369
|
-
return false;
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
// If a reverse proxy is in front, it will usually inject forwarding headers.
|
|
373
|
-
// Passwordless webhooks must never be accepted through a proxy.
|
|
374
|
-
const hasForwarded = Boolean(
|
|
375
|
-
req.headers?.["x-forwarded-for"] ||
|
|
376
|
-
req.headers?.["x-real-ip"] ||
|
|
377
|
-
req.headers?.["x-forwarded-host"],
|
|
378
|
-
);
|
|
379
|
-
return !hasForwarded;
|
|
380
|
-
}
|
|
381
|
-
|
|
382
341
|
export async function handleBlueBubblesWebhookRequest(
|
|
383
342
|
req: IncomingMessage,
|
|
384
343
|
res: ServerResponse,
|
|
@@ -394,15 +353,9 @@ export async function handleBlueBubblesWebhookRequest(
|
|
|
394
353
|
return true;
|
|
395
354
|
}
|
|
396
355
|
|
|
397
|
-
const body = await
|
|
356
|
+
const body = await readBlueBubblesWebhookBody(req, 1024 * 1024);
|
|
398
357
|
if (!body.ok) {
|
|
399
|
-
|
|
400
|
-
res.statusCode = 413;
|
|
401
|
-
} else if (body.error === "request body timeout") {
|
|
402
|
-
res.statusCode = 408;
|
|
403
|
-
} else {
|
|
404
|
-
res.statusCode = 400;
|
|
405
|
-
}
|
|
358
|
+
res.statusCode = body.statusCode;
|
|
406
359
|
res.end(body.error ?? "invalid payload");
|
|
407
360
|
console.warn(`[bluebubbles] webhook rejected: ${body.error ?? "invalid payload"}`);
|
|
408
361
|
return true;
|
|
@@ -466,31 +419,12 @@ export async function handleBlueBubblesWebhookRequest(
|
|
|
466
419
|
req.headers["x-bluebubbles-guid"] ??
|
|
467
420
|
req.headers["authorization"];
|
|
468
421
|
const guid = (Array.isArray(headerToken) ? headerToken[0] : headerToken) ?? guidParam ?? "";
|
|
469
|
-
|
|
470
|
-
const strictMatches: WebhookTarget[] = [];
|
|
471
|
-
const passwordlessTargets: WebhookTarget[] = [];
|
|
472
|
-
for (const target of targets) {
|
|
422
|
+
const matchedTarget = resolveSingleWebhookTarget(targets, (target) => {
|
|
473
423
|
const token = target.account.config.password?.trim() ?? "";
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
continue;
|
|
477
|
-
}
|
|
478
|
-
if (safeEqualSecret(guid, token)) {
|
|
479
|
-
strictMatches.push(target);
|
|
480
|
-
if (strictMatches.length > 1) {
|
|
481
|
-
break;
|
|
482
|
-
}
|
|
483
|
-
}
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
const matching =
|
|
487
|
-
strictMatches.length > 0
|
|
488
|
-
? strictMatches
|
|
489
|
-
: isDirectLocalLoopbackRequest(req)
|
|
490
|
-
? passwordlessTargets
|
|
491
|
-
: [];
|
|
424
|
+
return safeEqualSecret(guid, token);
|
|
425
|
+
});
|
|
492
426
|
|
|
493
|
-
if (
|
|
427
|
+
if (matchedTarget.kind === "none") {
|
|
494
428
|
res.statusCode = 401;
|
|
495
429
|
res.end("unauthorized");
|
|
496
430
|
console.warn(
|
|
@@ -499,14 +433,14 @@ export async function handleBlueBubblesWebhookRequest(
|
|
|
499
433
|
return true;
|
|
500
434
|
}
|
|
501
435
|
|
|
502
|
-
if (
|
|
436
|
+
if (matchedTarget.kind === "ambiguous") {
|
|
503
437
|
res.statusCode = 401;
|
|
504
438
|
res.end("ambiguous webhook target");
|
|
505
439
|
console.warn(`[bluebubbles] webhook rejected: ambiguous target match path=${path}`);
|
|
506
440
|
return true;
|
|
507
441
|
}
|
|
508
442
|
|
|
509
|
-
const target =
|
|
443
|
+
const target = matchedTarget.target;
|
|
510
444
|
target.statusSink?.({ lastInboundAt: Date.now() });
|
|
511
445
|
if (reaction) {
|
|
512
446
|
processReaction(reaction, target).catch((err) => {
|