@openclaw/bluebubbles 2026.2.6 → 2026.2.9

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/bluebubbles",
3
- "version": "2026.2.6",
3
+ "version": "2026.2.9",
4
4
  "description": "OpenClaw BlueBubbles channel plugin",
5
5
  "type": "module",
6
6
  "devDependencies": {
package/src/actions.ts CHANGED
@@ -86,7 +86,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
86
86
  if (!spec?.gate) {
87
87
  continue;
88
88
  }
89
- if (spec.unsupportedOnMacOS26 && macOS26) {
89
+ if ("unsupportedOnMacOS26" in spec && spec.unsupportedOnMacOS26 && macOS26) {
90
90
  continue;
91
91
  }
92
92
  if (gate(spec.gate)) {
@@ -26,7 +26,9 @@ const AUDIO_MIME_CAF = new Set(["audio/x-caf", "audio/caf"]);
26
26
  function sanitizeFilename(input: string | undefined, fallback: string): string {
27
27
  const trimmed = input?.trim() ?? "";
28
28
  const base = trimmed ? path.basename(trimmed) : "";
29
- return base || fallback;
29
+ const name = base || fallback;
30
+ // Strip characters that could enable multipart header injection (CWE-93)
31
+ return name.replace(/[\r\n"\\]/g, "_");
30
32
  }
31
33
 
32
34
  function ensureExtension(filename: string, extension: string, fallbackBase: string): string {
package/src/chat.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { OpenClawConfig } from "openclaw/plugin-sdk";
2
2
  import crypto from "node:crypto";
3
+ import path from "node:path";
3
4
  import { resolveBlueBubblesAccount } from "./accounts.js";
4
5
  import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js";
5
6
 
@@ -336,10 +337,13 @@ export async function setGroupIconBlueBubbles(
336
337
  const parts: Uint8Array[] = [];
337
338
  const encoder = new TextEncoder();
338
339
 
340
+ // Sanitize filename to prevent multipart header injection (CWE-93)
341
+ const safeFilename = path.basename(filename).replace(/[\r\n"\\]/g, "_") || "icon.png";
342
+
339
343
  // Add file field named "icon" as per API spec
340
344
  parts.push(encoder.encode(`--${boundary}\r\n`));
341
345
  parts.push(
342
- encoder.encode(`Content-Disposition: form-data; name="icon"; filename="${filename}"\r\n`),
346
+ encoder.encode(`Content-Disposition: form-data; name="icon"; filename="${safeFilename}"\r\n`),
343
347
  );
344
348
  parts.push(
345
349
  encoder.encode(`Content-Type: ${opts.contentType ?? "application/octet-stream"}\r\n\r\n`),
@@ -393,6 +393,48 @@ describe("BlueBubbles webhook monitor", () => {
393
393
  expect(res.statusCode).toBe(400);
394
394
  });
395
395
 
396
+ it("returns 400 when request body times out (Slow-Loris protection)", async () => {
397
+ vi.useFakeTimers();
398
+ try {
399
+ const account = createMockAccount();
400
+ const config: OpenClawConfig = {};
401
+ const core = createMockRuntime();
402
+ setBlueBubblesRuntime(core);
403
+
404
+ unregister = registerBlueBubblesWebhookTarget({
405
+ account,
406
+ config,
407
+ runtime: { log: vi.fn(), error: vi.fn() },
408
+ core,
409
+ path: "/bluebubbles-webhook",
410
+ });
411
+
412
+ // Create a request that never sends data or ends (simulates slow-loris)
413
+ const req = new EventEmitter() as IncomingMessage;
414
+ req.method = "POST";
415
+ req.url = "/bluebubbles-webhook";
416
+ req.headers = {};
417
+ (req as unknown as { socket: { remoteAddress: string } }).socket = {
418
+ remoteAddress: "127.0.0.1",
419
+ };
420
+ req.destroy = vi.fn();
421
+
422
+ const res = createMockResponse();
423
+
424
+ const handledPromise = handleBlueBubblesWebhookRequest(req, res);
425
+
426
+ // Advance past the 30s timeout
427
+ await vi.advanceTimersByTimeAsync(31_000);
428
+
429
+ const handled = await handledPromise;
430
+ expect(handled).toBe(true);
431
+ expect(res.statusCode).toBe(400);
432
+ expect(req.destroy).toHaveBeenCalled();
433
+ } finally {
434
+ vi.useRealTimers();
435
+ }
436
+ });
437
+
396
438
  it("authenticates via password query parameter", async () => {
397
439
  const account = createMockAccount({ password: "secret-token" });
398
440
  const config: OpenClawConfig = {};
package/src/monitor.ts CHANGED
@@ -361,14 +361,16 @@ function combineDebounceEntries(entries: BlueBubblesDebounceEntry[]): Normalized
361
361
 
362
362
  const webhookTargets = new Map<string, WebhookTarget[]>();
363
363
 
364
+ type BlueBubblesDebouncer = {
365
+ enqueue: (item: BlueBubblesDebounceEntry) => Promise<void>;
366
+ flushKey: (key: string) => Promise<void>;
367
+ };
368
+
364
369
  /**
365
370
  * Maps webhook targets to their inbound debouncers.
366
371
  * Each target gets its own debouncer keyed by a unique identifier.
367
372
  */
368
- const targetDebouncers = new Map<
369
- WebhookTarget,
370
- ReturnType<BlueBubblesCoreRuntime["channel"]["debounce"]["createInboundDebouncer"]>
371
- >();
373
+ const targetDebouncers = new Map<WebhookTarget, BlueBubblesDebouncer>();
372
374
 
373
375
  function resolveBlueBubblesDebounceMs(
374
376
  config: OpenClawConfig,
@@ -508,14 +510,29 @@ export function registerBlueBubblesWebhookTarget(target: WebhookTarget): () => v
508
510
  };
509
511
  }
510
512
 
511
- async function readJsonBody(req: IncomingMessage, maxBytes: number) {
513
+ async function readJsonBody(req: IncomingMessage, maxBytes: number, timeoutMs = 30_000) {
512
514
  const chunks: Buffer[] = [];
513
515
  let total = 0;
514
516
  return await new Promise<{ ok: boolean; value?: unknown; error?: string }>((resolve) => {
517
+ let done = false;
518
+ const finish = (result: { ok: boolean; value?: unknown; error?: string }) => {
519
+ if (done) {
520
+ return;
521
+ }
522
+ done = true;
523
+ clearTimeout(timer);
524
+ resolve(result);
525
+ };
526
+
527
+ const timer = setTimeout(() => {
528
+ finish({ ok: false, error: "request body timeout" });
529
+ req.destroy();
530
+ }, timeoutMs);
531
+
515
532
  req.on("data", (chunk: Buffer) => {
516
533
  total += chunk.length;
517
534
  if (total > maxBytes) {
518
- resolve({ ok: false, error: "payload too large" });
535
+ finish({ ok: false, error: "payload too large" });
519
536
  req.destroy();
520
537
  return;
521
538
  }
@@ -525,27 +542,30 @@ async function readJsonBody(req: IncomingMessage, maxBytes: number) {
525
542
  try {
526
543
  const raw = Buffer.concat(chunks).toString("utf8");
527
544
  if (!raw.trim()) {
528
- resolve({ ok: false, error: "empty payload" });
545
+ finish({ ok: false, error: "empty payload" });
529
546
  return;
530
547
  }
531
548
  try {
532
- resolve({ ok: true, value: JSON.parse(raw) as unknown });
549
+ finish({ ok: true, value: JSON.parse(raw) as unknown });
533
550
  return;
534
551
  } catch {
535
552
  const params = new URLSearchParams(raw);
536
553
  const payload = params.get("payload") ?? params.get("data") ?? params.get("message");
537
554
  if (payload) {
538
- resolve({ ok: true, value: JSON.parse(payload) as unknown });
555
+ finish({ ok: true, value: JSON.parse(payload) as unknown });
539
556
  return;
540
557
  }
541
558
  throw new Error("invalid json");
542
559
  }
543
560
  } catch (err) {
544
- resolve({ ok: false, error: err instanceof Error ? err.message : String(err) });
561
+ finish({ ok: false, error: err instanceof Error ? err.message : String(err) });
545
562
  }
546
563
  });
547
564
  req.on("error", (err) => {
548
- resolve({ ok: false, error: err instanceof Error ? err.message : String(err) });
565
+ finish({ ok: false, error: err instanceof Error ? err.message : String(err) });
566
+ });
567
+ req.on("close", () => {
568
+ finish({ ok: false, error: "connection closed" });
549
569
  });
550
570
  });
551
571
  }
@@ -1786,7 +1806,7 @@ async function processMessage(
1786
1806
  channel: "bluebubbles",
1787
1807
  accountId: account.accountId,
1788
1808
  peer: {
1789
- kind: isGroup ? "group" : "dm",
1809
+ kind: isGroup ? "group" : "direct",
1790
1810
  id: peerId,
1791
1811
  },
1792
1812
  });
@@ -1899,7 +1919,7 @@ async function processMessage(
1899
1919
  maxBytes,
1900
1920
  });
1901
1921
  const saved = await core.channel.media.saveMediaBuffer(
1902
- downloaded.buffer,
1922
+ Buffer.from(downloaded.buffer),
1903
1923
  downloaded.contentType,
1904
1924
  "inbound",
1905
1925
  maxBytes,
@@ -2331,7 +2351,7 @@ async function processMessage(
2331
2351
  },
2332
2352
  });
2333
2353
  }
2334
- if (shouldStopTyping) {
2354
+ if (shouldStopTyping && chatGuidForActions) {
2335
2355
  // Stop typing after streaming completes to avoid a stuck indicator.
2336
2356
  sendBlueBubblesTyping(chatGuidForActions, false, {
2337
2357
  cfg: config,
@@ -2424,7 +2444,7 @@ async function processReaction(
2424
2444
  channel: "bluebubbles",
2425
2445
  accountId: account.accountId,
2426
2446
  peer: {
2427
- kind: reaction.isGroup ? "group" : "dm",
2447
+ kind: reaction.isGroup ? "group" : "direct",
2428
2448
  id: peerId,
2429
2449
  },
2430
2450
  });
package/src/probe.ts CHANGED
@@ -16,7 +16,9 @@ export type BlueBubblesServerInfo = {
16
16
  computer_id?: string;
17
17
  };
18
18
 
19
- /** Cache server info by account ID to avoid repeated API calls */
19
+ /** Cache server info by account ID to avoid repeated API calls.
20
+ * Size-capped to prevent unbounded growth (#4948). */
21
+ const MAX_SERVER_INFO_CACHE_SIZE = 64;
20
22
  const serverInfoCache = new Map<string, { info: BlueBubblesServerInfo; expires: number }>();
21
23
  const CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
22
24
 
@@ -56,6 +58,13 @@ export async function fetchBlueBubblesServerInfo(params: {
56
58
  const data = payload?.data as BlueBubblesServerInfo | undefined;
57
59
  if (data) {
58
60
  serverInfoCache.set(cacheKey, { info: data, expires: Date.now() + CACHE_TTL_MS });
61
+ // Evict oldest entries if cache exceeds max size
62
+ if (serverInfoCache.size > MAX_SERVER_INFO_CACHE_SIZE) {
63
+ const oldest = serverInfoCache.keys().next().value;
64
+ if (oldest !== undefined) {
65
+ serverInfoCache.delete(oldest);
66
+ }
67
+ }
59
68
  }
60
69
  return data ?? null;
61
70
  } catch {
package/src/send.test.ts CHANGED
@@ -370,6 +370,16 @@ describe("send", () => {
370
370
  ).rejects.toThrow("requires text");
371
371
  });
372
372
 
373
+ it("throws when text becomes empty after markdown stripping", async () => {
374
+ // Edge case: input like "***" or "---" passes initial check but becomes empty after stripMarkdown
375
+ await expect(
376
+ sendMessageBlueBubbles("+15551234567", "***", {
377
+ serverUrl: "http://localhost:1234",
378
+ password: "test",
379
+ }),
380
+ ).rejects.toThrow("empty after markdown removal");
381
+ });
382
+
373
383
  it("throws when serverUrl is missing", async () => {
374
384
  await expect(sendMessageBlueBubbles("+15551234567", "Hello", {})).rejects.toThrow(
375
385
  "serverUrl is required",
@@ -438,6 +448,77 @@ describe("send", () => {
438
448
  expect(body.method).toBeUndefined();
439
449
  });
440
450
 
451
+ it("strips markdown formatting from outbound messages", async () => {
452
+ mockFetch
453
+ .mockResolvedValueOnce({
454
+ ok: true,
455
+ json: () =>
456
+ Promise.resolve({
457
+ data: [
458
+ {
459
+ guid: "iMessage;-;+15551234567",
460
+ participants: [{ address: "+15551234567" }],
461
+ },
462
+ ],
463
+ }),
464
+ })
465
+ .mockResolvedValueOnce({
466
+ ok: true,
467
+ text: () =>
468
+ Promise.resolve(
469
+ JSON.stringify({
470
+ data: { guid: "msg-uuid-stripped" },
471
+ }),
472
+ ),
473
+ });
474
+
475
+ const result = await sendMessageBlueBubbles(
476
+ "+15551234567",
477
+ "**Bold** and *italic* with `code`\n## Header",
478
+ {
479
+ serverUrl: "http://localhost:1234",
480
+ password: "test",
481
+ },
482
+ );
483
+
484
+ expect(result.messageId).toBe("msg-uuid-stripped");
485
+
486
+ const sendCall = mockFetch.mock.calls[1];
487
+ const body = JSON.parse(sendCall[1].body);
488
+ // Markdown should be stripped: no asterisks, backticks, or hashes
489
+ expect(body.message).toBe("Bold and italic with code\nHeader");
490
+ });
491
+
492
+ it("strips markdown when creating a new chat", async () => {
493
+ mockFetch
494
+ .mockResolvedValueOnce({
495
+ ok: true,
496
+ json: () => Promise.resolve({ data: [] }),
497
+ })
498
+ .mockResolvedValueOnce({
499
+ ok: true,
500
+ text: () =>
501
+ Promise.resolve(
502
+ JSON.stringify({
503
+ data: { guid: "new-msg-stripped" },
504
+ }),
505
+ ),
506
+ });
507
+
508
+ const result = await sendMessageBlueBubbles("+15550009999", "**Welcome** to the _chat_!", {
509
+ serverUrl: "http://localhost:1234",
510
+ password: "test",
511
+ });
512
+
513
+ expect(result.messageId).toBe("new-msg-stripped");
514
+
515
+ const createCall = mockFetch.mock.calls[1];
516
+ expect(createCall[0]).toContain("/api/v1/chat/new");
517
+ const body = JSON.parse(createCall[1].body);
518
+ // Markdown should be stripped
519
+ expect(body.message).toBe("Welcome to the chat!");
520
+ });
521
+
441
522
  it("creates a new chat when handle target is missing", async () => {
442
523
  mockFetch
443
524
  .mockResolvedValueOnce({
package/src/send.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { OpenClawConfig } from "openclaw/plugin-sdk";
2
2
  import crypto from "node:crypto";
3
+ import { stripMarkdown } from "openclaw/plugin-sdk";
3
4
  import { resolveBlueBubblesAccount } from "./accounts.js";
4
5
  import {
5
6
  extractHandleFromChatGuid,
@@ -332,6 +333,7 @@ async function createNewChatWithMessage(params: {
332
333
  const payload = {
333
334
  addresses: [params.address],
334
335
  message: params.message,
336
+ tempGuid: `temp-${crypto.randomUUID()}`,
335
337
  };
336
338
  const res = await blueBubblesFetchWithTimeout(
337
339
  url,
@@ -377,6 +379,11 @@ export async function sendMessageBlueBubbles(
377
379
  if (!trimmedText.trim()) {
378
380
  throw new Error("BlueBubbles send requires text");
379
381
  }
382
+ // Strip markdown early and validate - ensures messages like "***" or "---" don't become empty
383
+ const strippedText = stripMarkdown(trimmedText);
384
+ if (!strippedText.trim()) {
385
+ throw new Error("BlueBubbles send requires text (message was empty after markdown removal)");
386
+ }
380
387
 
381
388
  const account = resolveBlueBubblesAccount({
382
389
  cfg: opts.cfg ?? {},
@@ -406,7 +413,7 @@ export async function sendMessageBlueBubbles(
406
413
  baseUrl,
407
414
  password,
408
415
  address: target.address,
409
- message: trimmedText,
416
+ message: strippedText,
410
417
  timeoutMs: opts.timeoutMs,
411
418
  });
412
419
  }
@@ -419,7 +426,7 @@ export async function sendMessageBlueBubbles(
419
426
  const payload: Record<string, unknown> = {
420
427
  chatGuid,
421
428
  tempGuid: crypto.randomUUID(),
422
- message: trimmedText,
429
+ message: strippedText,
423
430
  };
424
431
  if (needsPrivateApi) {
425
432
  payload.method = "private-api";
package/src/types.ts CHANGED
@@ -1,5 +1,5 @@
1
- export type DmPolicy = "pairing" | "allowlist" | "open" | "disabled";
2
- export type GroupPolicy = "open" | "disabled" | "allowlist";
1
+ import type { DmPolicy, GroupPolicy } from "openclaw/plugin-sdk";
2
+ export type { DmPolicy, GroupPolicy };
3
3
 
4
4
  export type BlueBubblesGroupConfig = {
5
5
  /** If true, only respond in this group when mentioned. */