Skip to content

fix(bluebubbles): dedupe inbound webhooks across restarts (#19176, #12053)#66810

Closed
omarshahine wants to merge 1 commit intomainfrom
lobster/bb-inbound-dedupe
Closed

fix(bluebubbles): dedupe inbound webhooks across restarts (#19176, #12053)#66810
omarshahine wants to merge 1 commit intomainfrom
lobster/bb-inbound-dedupe

Conversation

@omarshahine
Copy link
Copy Markdown
Contributor

Summary

BlueBubbles `MessagePoller` keeps a ~1-week lookback and re-fires `new-message` webhooks after BB Server restart or reconnection. With no sequence number or ack in the BB webhook protocol, the gateway previously had no way to recognize replays and would happily re-reply to messages it had already handled before the restart — producing duplicated outbound messages, and confusing replies to stale inbound that the user had already moved on from (see #19176, #12053).

This PR adds a persistent, file-backed inbound dedupe keyed by message GUID, modeled after the same `createPersistentDedupe` pattern used by the Feishu plugin.

  • TTL = 7 days (matches BB's lookback window, so any replay is guaranteed to land on a remembered GUID).
  • On-disk state at `~/.openclaw/bluebubbles/inbound-dedupe/.json` — survives gateway restarts, which is the crucial property the previous in-memory dedupe layers can't provide.
  • Applied at the top of `processMessage`, before any downstream work, so stale replays never reach pairing, dispatch, or the reply cache.

Why this approach

Other channels with monotonic sequence IDs (Telegram's `update_id`, Matrix's sync token, Discord's gateway sequence) can dedupe natively via protocol. BlueBubbles does not expose anything like that, so an identity-based persistent dedupe at the message layer is the closest equivalent that fits how BB actually delivers webhooks.

Interaction with edit events (`updated-message`)

PR #52277 raised a related concern: if dedupe keys are GUID-only, a legitimate `updated-message` event would share a GUID with its original `new-message` and get dropped as a duplicate.

In the current codebase this cannot happen: `monitor.ts` routes `updated-message` payloads differently — without a reaction they are dropped at the webhook layer ("ignored without reaction"), and with a reaction they flow through `processReaction`, not `processMessage`. Our dedupe sits inside `processMessage`, so only `new-message` events are gated. Edits can't collide today.

If the separate work in #52277 lands and begins routing text-edit bodies into `processMessage`, the dedupe key will need to expand to include event type and edit metadata (e.g. `guid + eventType + (dateEdited||"")`) so an edit is treated as a distinct key. That's a straightforward forward-compatible change when it's needed.

Validation

  • New scoped test: `extensions/bluebubbles/src/inbound-dedupe.test.ts` (3 cases covering claim/reject, per-account scoping, missing GUID).
  • Full BlueBubbles suite passes (387/387).
  • End-to-end tested on a live gateway (macOS 26.3, BB Server 1.9.x) with a synthetic replay script: first webhook gets processed + recorded, second webhook with the same GUID is dropped before dispatch, dedupe file timestamp unchanged. Sample script lives in `scripts/test-bb-dedupe.sh` for the PR lifecycle; remove before merge if not desired.
  • `pnpm check` green.

Credits

Re-creates and improves on the focused fix from #31159 by @dashhuang — same behavioral goal (drop stale BB webhook replays), implemented as a persistent on-disk dedupe so it actually survives the gateway-restart case that drives the bug, and without the module-global mutable state that made the original patch need test-reset plumbing.

Fixes #19176, #12053.

Test plan

  • New scoped test passes (`pnpm test extensions/bluebubbles/src/inbound-dedupe.test.ts`)
  • Full BlueBubbles suite passes (`pnpm test extensions/bluebubbles/`)
  • `pnpm check` green
  • Live test on macOS 26.3 with BB Server — synthetic replay dropped, dedupe file persisted across gateway restart
  • Maintainer review

…2053)

BlueBubbles MessagePoller replays its ~1-week lookback window as new-message
webhooks after BB Server restart or reconnect. Without persistent dedup, the
gateway re-replies to messages it already handled before the restart.

Add a persistent file-backed GUID dedupe (TTL=7d, matching BB's lookback
window) at the top of processMessage, using the same createClaimableDedupe
SDK primitive as Feishu. The on-disk store at
~/.openclaw/bluebubbles/inbound-dedupe/<account>.json survives gateway
restarts. Claim/finalize/release semantics ensure transient delivery failures
release the GUID so a later replay can retry, while successful deliveries are
committed and block future replays.

Fixes #19176, #12053.
@omarshahine omarshahine added channel: bluebubbles Channel integration: bluebubbles maintainer Maintainer-authored PR labels Apr 14, 2026
@omarshahine
Copy link
Copy Markdown
Contributor Author

Branch name is stuck — GitHub won't fire CI events for it. Moving to a fresh branch.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Apr 14, 2026

Greptile Summary

Adds a persistent file-backed GUID dedupe to the BlueBubbles processMessage path, using the existing createClaimableDedupe SDK primitive (same pattern as Feishu) to survive gateway restarts and block the MessagePoller 7-day lookback replays that were producing duplicate outbound replies. The claim/finalize/release semantics correctly release on transient delivery failures so later replays can retry, and the balloon-event key aliasing mirrors the debouncer's existing buildKey logic.

Confidence Score: 5/5

Safe to merge — the core dedupe logic is correct, well-tested for the claim/release lifecycle, and consistent with the existing Feishu pattern.

All remaining findings are P2: a slightly misleading code comment and missing unit tests for the balloon-event key-resolution function. Neither blocks the fix or affects correctness of the production path.

No files require special attention.

Prompt To Fix All With AI
This is a comment left during a code review.
Path: extensions/bluebubbles/src/inbound-dedupe.ts
Line: 26-27

Comment:
**Misleading comment about persistence test**

The comment "Stable-per-pid so the scoped dedupe test can observe persistence" implies a test exercises the file-backed path, but `inbound-dedupe.test.ts` calls `_resetBlueBubblesInboundDedupForTest()` in `beforeEach`, which installs a memory-only impl for every test. No current test actually reaches disk. The stable-per-pid path is a useful safety net for future persistence tests, but the comment overstates what the current test suite covers.

```suggestion
    // Stable-per-pid so concurrent test workers do not share the same temp dir
    // if a future test exercises file-backed persistence.
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: extensions/bluebubbles/src/inbound-dedupe.test.ts
Line: 1-5

Comment:
**`resolveBlueBubblesInboundDedupeKey` has no test coverage**

The new `resolveBlueBubblesInboundDedupeKey` function contains non-trivial logic (the balloon-event key-aliasing path: when both `balloonBundleId` and `associatedMessageGuid` are present, use the `associatedMessageGuid` rather than `messageId`) and a documented tradeoff around post-merge balloon+text ordering. Since this key-resolution function determines what the dedupe actually gates on, a few targeted tests would help lock the contract and make the tradeoff concrete:
- Regular message → keyed by `messageId`
- Balloon with both fields → keyed by `associatedMessageGuid`
- Balloon without `balloonBundleId` (reply context only) → keyed by `messageId`

How can I resolve this? If you propose a fix, please make it concise.

Reviews (1): Last reviewed commit: "fix(bluebubbles): dedupe inbound webhook..." | Re-trigger Greptile

omarshahine pushed a commit that referenced this pull request Apr 14, 2026
…66810

The inbound-dedupe PR was reopened as #66810 (#66230 closed without
merging at 21:10:28Z, #66810 created 7s later on the same
lobster/bb-inbound-dedupe branch). Updating code comments and the
catchup CHANGELOG entry to point at the live PR. The branch dependency
chain (this PR stacked on lobster/bb-inbound-dedupe) is unchanged.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Comment on lines +26 to +27
// Stable-per-pid so the scoped dedupe test can observe persistence.
const name = "openclaw-vitest-" + process.pid;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Misleading comment about persistence test

The comment "Stable-per-pid so the scoped dedupe test can observe persistence" implies a test exercises the file-backed path, but inbound-dedupe.test.ts calls _resetBlueBubblesInboundDedupForTest() in beforeEach, which installs a memory-only impl for every test. No current test actually reaches disk. The stable-per-pid path is a useful safety net for future persistence tests, but the comment overstates what the current test suite covers.

Suggested change
// Stable-per-pid so the scoped dedupe test can observe persistence.
const name = "openclaw-vitest-" + process.pid;
// Stable-per-pid so concurrent test workers do not share the same temp dir
// if a future test exercises file-backed persistence.
Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/bluebubbles/src/inbound-dedupe.ts
Line: 26-27

Comment:
**Misleading comment about persistence test**

The comment "Stable-per-pid so the scoped dedupe test can observe persistence" implies a test exercises the file-backed path, but `inbound-dedupe.test.ts` calls `_resetBlueBubblesInboundDedupForTest()` in `beforeEach`, which installs a memory-only impl for every test. No current test actually reaches disk. The stable-per-pid path is a useful safety net for future persistence tests, but the comment overstates what the current test suite covers.

```suggestion
    // Stable-per-pid so concurrent test workers do not share the same temp dir
    // if a future test exercises file-backed persistence.
```

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +1 to +5
import { beforeEach, describe, expect, it } from "vitest";
import {
_resetBlueBubblesInboundDedupForTest,
claimBlueBubblesInboundMessage,
} from "./inbound-dedupe.js";
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 resolveBlueBubblesInboundDedupeKey has no test coverage

The new resolveBlueBubblesInboundDedupeKey function contains non-trivial logic (the balloon-event key-aliasing path: when both balloonBundleId and associatedMessageGuid are present, use the associatedMessageGuid rather than messageId) and a documented tradeoff around post-merge balloon+text ordering. Since this key-resolution function determines what the dedupe actually gates on, a few targeted tests would help lock the contract and make the tradeoff concrete:

  • Regular message → keyed by messageId
  • Balloon with both fields → keyed by associatedMessageGuid
  • Balloon without balloonBundleId (reply context only) → keyed by messageId
Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/bluebubbles/src/inbound-dedupe.test.ts
Line: 1-5

Comment:
**`resolveBlueBubblesInboundDedupeKey` has no test coverage**

The new `resolveBlueBubblesInboundDedupeKey` function contains non-trivial logic (the balloon-event key-aliasing path: when both `balloonBundleId` and `associatedMessageGuid` are present, use the `associatedMessageGuid` rather than `messageId`) and a documented tradeoff around post-merge balloon+text ordering. Since this key-resolution function determines what the dedupe actually gates on, a few targeted tests would help lock the contract and make the tradeoff concrete:
- Regular message → keyed by `messageId`
- Balloon with both fields → keyed by `associatedMessageGuid`
- Balloon without `balloonBundleId` (reply context only) → keyed by `messageId`

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 82bce2647e

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +628 to +631
const claim = await claimBlueBubblesInboundMessage({
guid: dedupeKey,
accountId: account.accountId,
onDiskError: (error) =>
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Skip dedupe claim for fromMe messages

The new wrapper claims and commits dedupe keys before the existing message.fromMe early-return path runs, so outbound/self messages now consume slots in the persistent inbound replay file. Because inbound-dedupe.ts caps storage at 50,000 entries, high-volume accounts can evict real inbound GUIDs earlier than the 7-day TTL, allowing restart replays to slip through and be re-processed. Gate this dedupe to inbound user messages (or at least after the fromMe branch) so outbound traffic does not age out the keys this fix depends on.

Useful? React with 👍 / 👎.

@aisle-research-bot
Copy link
Copy Markdown

aisle-research-bot Bot commented Apr 14, 2026

🔒 Aisle Security Analysis

We found 4 potential security issue(s) in this PR:

# Severity Title
1 🟡 Medium Sensitive identifiers (message GUID and senderId) logged on inbound dedupe drops
2 🟡 Medium Log injection / sensitive data exposure via unsanitized error logging in BlueBubbles reply error handler
3 🟡 Medium Symlink-following file write in atomic JSON writer enables local arbitrary file overwrite via BlueBubbles inbound dedupe persistence
4 🔵 Low Unbounded attacker-controlled fields logged in inbound dedupe path (potential log/CPU DoS)
1. 🟡 Sensitive identifiers (message GUID and senderId) logged on inbound dedupe drops
Property Value
Severity Medium
CWE CWE-532
Location extensions/bluebubbles/src/monitor-processing.ts:634-640

Description

processMessage() logs the inbound dedupe key (BlueBubbles messageId / associatedMessageGuid) and senderId whenever a message is dropped as a duplicate or inflight.

These values are likely stable identifiers that can be correlated with users/conversations (e.g., sender phone/email/handle and message GUIDs). Writing them to logs can expose PII/sensitive metadata to:

  • operators who should not see message metadata
  • log aggregation / SIEM systems with broader access than the app runtime
  • incident/support artifacts shared externally

Vulnerable code:

if (claim.kind === "duplicate" || claim.kind === "inflight") {
  logVerbose(
    core,
    runtime,
    `drop: ${claim.kind} inbound key=${sanitizeForLog(dedupeKey ?? "")} sender=${sanitizeForLog(message.senderId)}`,
  );
  return;
}

Recommendation

Avoid logging raw sender identifiers and message GUIDs. Prefer one of:

  • omit these fields entirely
  • log a short, non-reversible digest (stable for correlation but not directly identifying)
  • log only a minimal prefix/suffix (last 4) if needed for debugging

Example (hash + truncation):

import { createHash } from "node:crypto";

function hashForLog(value: string): string {
  return createHash("sha256").update(value, "utf8").digest("hex").slice(0, 12);
}

const keyTag = dedupeKey ? hashForLog(dedupeKey) : "";
const senderTag = message.senderId ? hashForLog(String(message.senderId)) : "";

logVerbose(core, runtime, `drop: ${claim.kind} inbound key_hash=${keyTag} sender_hash=${senderTag}`);

Also consider gating even the hashed output behind an explicit debug setting, and ensure logs are access-controlled and retained minimally.

2. 🟡 Log injection / sensitive data exposure via unsanitized error logging in BlueBubbles reply error handler
Property Value
Severity Medium
CWE CWE-117
Location extensions/bluebubbles/src/monitor-processing.ts:1694-1709

Description

In processMessageAfterDedupe's dispatcher onError handler, the code logs String(err) directly to runtime.error.

  • err can originate from sendMessageBlueBubbles, which throws errors that include untrusted BlueBubbles server response bodies (e.g., errorText = await res.text()), potentially containing attacker-controlled content, CR/LF, control characters, or sensitive details.
  • Logging unfiltered error strings enables log forging/injection (newline/control chars) and may unintentionally persist sensitive data (server error payloads, stack traces, message content) into centralized logs.
  • A sanitizeForLog() helper was introduced in the same file and used for other log lines, but is not applied here.

Vulnerable code:

onError: (err, info) => {
  if (info.kind === "final") {
    dedupeSignal.deliveryFailed = true;
  }
  runtime.error?.(`BlueBubbles ${info.kind} reply failed: ${String(err)}`);
},

Recommendation

Sanitize or structure error logging to prevent log forging and reduce sensitive data exposure.

Option A (reuse existing helper):

runtime.error?.(
  `BlueBubbles ${info.kind} reply failed: ${sanitizeForLog(err)}`,
);

Option B (structured logging with minimal fields):

runtime.error?.(
  `BlueBubbles reply failed kind=${sanitizeForLog(info.kind)} err=${sanitizeForLog((err as Error)?.message ?? err)}`
);

Additionally consider avoiding logging raw HTTP response bodies from upstream services; prefer status codes + correlation ids.

3. 🟡 Symlink-following file write in atomic JSON writer enables local arbitrary file overwrite via BlueBubbles inbound dedupe persistence
Property Value
Severity Medium
CWE CWE-59
Location src/infra/json-files.ts:20-27

Description

extensions/bluebubbles/src/inbound-dedupe.ts introduces a new persistent on-disk store (per-account namespace) that writes JSON files under the OpenClaw state directory. The underlying async “atomic” writer (writeTextAtomic/writeJsonAtomic) does not defend against pre-existing symlinks/hardlinks at the destination path.

Impact (local attacker / same-host):

  • If an attacker can create/replace ~/.openclaw/bluebubbles/inbound-dedupe/<name>.json (or any parent path component) with a symlink to another file writable by the gateway process, the dedupe commit will write JSON content to the symlink target.
  • On Windows, the fallback path explicitly uses copyFile(tempPath, filePath), which will follow a symlink at filePath and overwrite the link target.
  • There is no lstat/realpath verification of the final destination prior to the overwrite, and no O_NOFOLLOW style protection.

Vulnerable code (Windows fallback overwrite):

// src/infra/json-files.ts
await fs.copyFile(tempPath, filePath);

This becomes reachable via BlueBubbles inbound dedupe persistence, which derives a per-account file path and ultimately calls writeJsonAtomic(...) through writeJsonFileAtomically(...).

Recommendation

Harden atomic writes against symlink/hardlink attacks (CWE-59):

  • Before replacing the destination, verify the destination path is not a symlink (lstat) and that all parent directories are not symlinks.
  • Prefer opening the destination with platform-appropriate no-follow flags (where available) and/or use fs.rename to a path that was created securely.
  • On Windows fallback, avoid copyFile to a path that could be a symlink; instead create the destination file securely (e.g., open with exclusive flags) after validating lstat, or remove and recreate the destination only after verifying it is a regular file.

Example (sketch):

import fs from "node:fs/promises";

async function assertNotSymlink(p: string) {
  try {
    const st = await fs.lstat(p);
    if (st.isSymbolicLink()) throw new Error(`Refusing to write through symlink: ${p}`);
  } catch (e: any) {
    if (e?.code !== "ENOENT") throw e;
  }
}

await assertNotSymlink(filePath);// then proceed with rename/copy

Additionally, consider resolving resolveStateDirFromEnv() once and ensuring the created state subdirectories have restrictive permissions and are not symlinks.

4. 🔵 Unbounded attacker-controlled fields logged in inbound dedupe path (potential log/CPU DoS)
Property Value
Severity Low
CWE CWE-117
Location extensions/bluebubbles/src/monitor-processing.ts:588-640

Description

The inbound-dedupe wrapper logs dedupeKey, message.senderId, and arbitrary error/finalizeError objects after only stripping control characters, without any length truncation.

If an attacker can send authenticated BlueBubbles webhook events (or if the webhook token is leaked), they can supply extremely large strings in fields that flow into these logs (e.g., senderId is taken from the webhook payload in normalizeWebhookMessage() without a maximum length). This can cause:

  • Log amplification / disk exhaustion (very large log lines)
  • CPU/memory overhead from String(value) on large objects/strings

Vulnerable code (new logging):

function sanitizeForLog(value: unknown): string {
  return String(value).replace(/[\r\n\t\p{C}]/gu, " ");
}
...
`drop: ${claim.kind} inbound key=${sanitizeForLog(dedupeKey ?? "")} sender=${sanitizeForLog(message.senderId)}`
...
`inbound-dedupe: finalize failed for key=${sanitizeForLog(dedupeKey ?? "")}: ${sanitizeForLog(finalizeError)}`

Recommendation

Add size limits when logging values derived from webhook payloads and error objects.

For example, cap log fields to a small, safe maximum (e.g., 200 chars) and consider logging hashes instead of raw identifiers:

function sanitizeForLog(value: unknown, maxLen = 200): string {
  const s = String(value).replace(/[\r\n\t\p{C}]/gu, " ");
  return s.length > maxLen ? s.slice(0, maxLen) + "…" : s;
}// Or log hashes for high-entropy IDs// const keyHash = createHash("sha256").update(dedupeKey ?? "").digest("hex").slice(0, 12);

Also consider avoiding String(error) on arbitrary objects; prefer formatErrorMessage(err) or a bounded serialization that cannot explode in size.


Analyzed PR: #66810 at commit 82bce26

Last updated on: 2026-04-14T21:20:02Z

omarshahine pushed a commit that referenced this pull request Apr 14, 2026
…66816

The inbound-dedupe PR was reopened again as #66816 (closed-without-merge
trail: #66230#66810#66816). The branch was force-pushed and the
new PR uses the parallel `fix/bb-inbound-dedupe` branch. Updating code
comments and the catchup CHANGELOG entry to point at the live PR.
Stacking on top of the dedupe branch will be addressed in a follow-up
rebase.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@omarshahine omarshahine deleted the lobster/bb-inbound-dedupe branch April 14, 2026 22:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

channel: bluebubbles Channel integration: bluebubbles maintainer Maintainer-authored PR size: M

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[bluebubbles] Add maxMessageAge filter to drop stale re-delivered webhooks

1 participant