Skip to content

bluebubbles: consolidate HTTP traffic through typed BlueBubblesClient#68234

Merged
omarshahine merged 5 commits intomainfrom
feat/bluebubbles-client-introduce
Apr 19, 2026
Merged

bluebubbles: consolidate HTTP traffic through typed BlueBubblesClient#68234
omarshahine merged 5 commits intomainfrom
feat/bluebubbles-client-introduce

Conversation

@omarshahine
Copy link
Copy Markdown
Contributor

@omarshahine omarshahine commented Apr 17, 2026

Ready for review. Smoke-tested live on Lobster on 3ef012f20c.

Summary

All outbound BlueBubbles HTTP calls now route through a single typed BlueBubblesClient that resolves SSRF policy and auth material once at construction. Previously each of the 17 callsites computed its own SsrFPolicy and threaded it through blueBubblesFetchWithTimeout — the per-callsite divergence is what caused #34749 (image attachments blocked on localhost) and #59722 (reactions blocked on private IPs).

What closes

How

New extensions/bluebubbles/src/client.ts:

  • BlueBubblesClient class with typed operations (request, requestJson, requestMultipart, ping, getServerInfo, react, getMessageAttachments, downloadAttachment).
  • createBlueBubblesClient({cfg, accountId, serverUrl, password, ...}) for full-config callers.
  • createBlueBubblesClientFromParts({baseUrl, password, allowPrivateNetwork, ...}) for low-level helpers that already have the resolved tuple.
  • Three-mode SSRF policy resolved once at construction. All three modes go through fetchWithSsrFGuard — mode 3 returns {} (default-deny guarded), never undefined, so a config that opts out of private networks cannot slip onto the unguarded path:
    1. { allowPrivateNetwork: true } — user opted in
    2. { allowedHostnames: [trustedHost] } — narrow allowlist
    3. {} — default-deny guarded (opt-out or unparseable URL)
  • Pluggable BlueBubblesAuthStrategy with blueBubblesQueryStringAuth (default, preserves current ?password=) and blueBubblesHeaderAuth so the BlueBubbles: move ?password= URL auth to header-based auth across all BB API calls #66869 header-auth cutover becomes a one-line default flip. Both strategies carry a stable id that participates in the client cache fingerprint so different strategies for the same credential don't collapse.

Migrated: attachments.ts, reactions.ts, probe.ts, chat.ts, send.ts, history.ts, catchup.ts, monitor-processing.ts all now route through the client. Public function signatures preserved for compatibility.

Multipart + download auth plumbing: postMultipartFormData accepts extraHeaders and client.requestMultipart forwards prepared.init.headers; downloadAttachment merges prepared.init.headers into the fetchImpl init so a future blueBubblesHeaderAuth flip covers attachment uploads and downloads without a follow-up refactor.

Test fixtures: monitor.test.ts and monitor.webhook-auth.test.ts install the shared SSRF-guard passthrough (createBlueBubblesFetchGuardPassthroughInstaller) — required because the consolidation moves callers from the unguarded fallback onto the mode-2 allowlist for self-hosted BB servers.

Size

465 tests green (was 462 at initial open). New coverage locks in the header-auth plumbing, the cache-fingerprint authStrategy dimension, and the mode-3 "ssrfPolicy is never undefined" invariant.

Verification

  • pnpm test extensions/bluebubbles — 22 files / 465 tests pass
  • pnpm lint extensions/bluebubbles/src/ — 0 warnings / 0 errors
  • pnpm build — clean on this branch (the amazon-bedrock-mantle staging error referenced at initial open has been resolved upstream)
  • pnpm tsgo — clean on bluebubbles; remaining errors (discord / qa-lab / src/commands) are pre-existing on origin/main and unrelated to this change
  • Smoke-tested on Lobster (macOS BB Server over private LAN, pid-restart on 3ef012f20c)

Review feedback addressed

  • P1 (greptile) requestMultipart / downloadAttachment drop auth headers — 6dfb043d80.
  • P2 (greptile) cache fingerprint excludes authStrategy6dfb043d80 (stable id field on the strategy interface).
  • P1 (codex × 2) explicit dangerouslyAllowPrivateNetwork: false not honored on probe / send paths — 3ef012f20c (mode 3 returns guarded {}, type tightened to SsrFPolicy; invariant test asserts ssrfPolicy is always defined).

Deferred to follow-ups (intentionally NOT in this PR)

Blocks #57181, #60715 (both become instance-level tests against BlueBubblesClient once this lands).

🤖 Generated with Claude Code

@openclaw-barnacle openclaw-barnacle Bot added channel: bluebubbles Channel integration: bluebubbles size: XL maintainer Maintainer-authored PR labels Apr 17, 2026
@omarshahine omarshahine marked this pull request as ready for review April 17, 2026 23:08
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: 6ce51f7bd7

ℹ️ 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 +451 to +455
baseUrl: resolved.baseUrl,
password: resolved.password,
});
const cached = clientFingerprints.get(cacheKey);
if (cached && cached.fingerprint === fingerprint) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Include policy inputs in BlueBubbles client cache key

The cache fingerprint only includes {baseUrl,password}, so createBlueBubblesClient can return a stale instance when config changes that alter client behavior but keep the same credentials (for example network.dangerouslyAllowPrivateNetwork, or other constructor-level options like auth strategy/default timeout). In this commit, the SSRF policy is resolved once in the constructor, so reusing a cached client after such a config update keeps the old policy active; because invalidateBlueBubblesClient is not called in production paths, these updates won’t take effect until restart.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Partially addressed. 6dfb043d80 brought authStrategy into the fingerprint via a stable id, so strategy swaps for the same credential rebuild. Leaving this thread open because the allowPrivateNetwork/timeoutMs dimensions you also flagged are not yet fingerprinted — production reload paths today go through gateway restart (which blows away the cache), so it's asymptomatic, but if a future hot-reload path lands it would resurface. Will track as a follow-up rather than expanding the fingerprint reactively.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Apr 17, 2026

Greptile Summary

This PR consolidates all BlueBubbles outbound HTTP traffic through a single typed BlueBubblesClient that resolves the SSRF policy and auth material once at construction. The three-mode SSRF policy resolution correctly fixes the localhost/private-IP blockage (#34749, #59722), and the 28 new tests cover the key regression paths well.

One correctness issue is worth addressing before the header-auth cutover (#66869): requestMultipart calls buildAuthorizedRequest (which decorates auth onto prepared.init) but then passes only prepared.url to postMultipartFormData, silently discarding the auth headers. postMultipartFormData in multipart.ts builds its own hardcoded headers with no provision for additional entries. The same gap exists in downloadAttachment, which similarly constructs prepared.init but never forwards it to fetchRemoteMedia. Flipping the default to blueBubblesHeaderAuth as described would silently break attachment uploads, group-icon sets, and attachment downloads — so multipart.ts and the fetchRemoteMedia call path need corresponding updates before that switch.

Confidence Score: 4/5

Safe to merge for the query-string auth default; the header-auth readiness gap in requestMultipart and downloadAttachment should be fixed before #66869 lands.

The SSRF consolidation logic is correct, tests are thorough, and the net effect is a meaningful bug fix for #34749 and #59722. The P1 finding (auth headers dropped for multipart/download in header-auth mode) does not affect the current QS-auth default but makes the 'one-line flip' claim inaccurate and will silently break attachment operations when #66869 is applied. The P2 cache fingerprint issue is unlikely to bite in practice but is a latent correctness gap in the public API.

extensions/bluebubbles/src/client.ts — requestMultipart (lines 269-287) and downloadAttachment (lines 383-403) both need to forward the auth-decorated init to their respective lower-level fetch helpers before header auth is enabled.

Prompt To Fix All With AI
This is a comment left during a code review.
Path: extensions/bluebubbles/src/client.ts
Line: 269-287

Comment:
**`requestMultipart` discards header-auth credentials — "one-line flip" claim is incomplete**

`buildAuthorizedRequest` applies auth to `prepared.init` (headers for `blueBubblesHeaderAuth`, URL for query-string). `requestMultipart` uses `prepared.url` but drops `prepared.init` entirely when calling `postMultipartFormData`. Since `postMultipartFormData` in `multipart.ts` builds its own hardcoded headers object (`{ "Content-Type": "multipart/form-data; boundary=..." }`) with no provision for additional headers, switching the default to `blueBubblesHeaderAuth` would silently omit auth for every attachment upload (`/api/v1/message/attachment`) and group-icon set (`/api/v1/chat/{guid}/icon`).

The same issue applies to `downloadAttachment` (lines 383–403): `prepared.init.headers` is built with the auth header but is never passed to `fetchRemoteMedia` or the `fetchImpl` callback.

Fixing this requires `postMultipartFormData` to accept an optional `extraHeaders` param, and `downloadAttachment` needs to merge `prepared.init` into the `fetchImpl` call. Neither change is here, so the PR description's "one-line default flip" for #66869 is inaccurate as-is.

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/client.ts
Line: 431-456

Comment:
**Cache fingerprint excludes `authStrategy` — wrong strategy can be silently returned**

`buildClientFingerprint` keys only on `baseUrl` and `password`. If `createBlueBubblesClient` is called first with the default strategy and later with `authStrategy: blueBubblesHeaderAuth` for the same account (same URL + credential), the second call returns the cached QS-auth instance rather than building a new header-auth client. This would be silent and hard to debug, since all current call-sites use the default strategy but the option is in the public API.

Consider including a stable auth-strategy identifier in the fingerprint so that different strategies for the same account don't collapse onto the same cached entry.

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

Reviews (1): Last reviewed commit: "bluebubbles: consolidate HTTP traffic th..." | Re-trigger Greptile

Comment thread extensions/bluebubbles/src/client.ts
Comment thread extensions/bluebubbles/src/client.ts Outdated
@omarshahine omarshahine force-pushed the feat/bluebubbles-client-introduce branch from 6ce51f7 to 6dfb043 Compare April 18, 2026 00:05
omarshahine added a commit that referenced this pull request Apr 18, 2026
…ache fingerprint)

Greptile findings from the first review cycle on #68234 — both real, both
asymptomatic under today's query-string auth default but blocking for the
header-auth flip (#66869).

P1: Thread auth-decorated headers through multipart + media paths.
- `postMultipartFormData` now accepts an optional `extraHeaders` param and
  merges caller headers with its own Content-Type (Content-Type always wins
  so the multipart boundary stays authoritative).
- `client.requestMultipart` forwards `prepared.init.headers` — attachment
  uploads and group-icon sets keep working under header auth.
- `client.downloadAttachment` fetchImpl merges `prepared.init.headers` with
  runtime-supplied headers (runtime wins on conflict, e.g. Range for partial
  reads) — attachment downloads via `fetchRemoteMedia` now carry auth too.

P2: Include auth strategy in the client cache fingerprint.
- `BlueBubblesAuthStrategy` gets a stable `id` field
  (`query-string` / `header:<name>`).
- Built-in factories set it; the cache key is now
  `{baseUrl}|{password}|{authStrategyId}` so different strategies for the
  same account + credential no longer silently share a cached instance.

Three new regression tests pin the above behavior (465 total, was 462).
Full BB suite green, lint clean.
@aisle-research-bot
Copy link
Copy Markdown

aisle-research-bot Bot commented Apr 18, 2026

🔒 Aisle Security Analysis

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

# Severity Title
1 🟠 High SSRF guard bypass via allowedHostnames disabling private-IP/DNS-rebinding checks for BlueBubbles server hostname
2 🟡 Medium BlueBubbles API password appended to request URLs by default (query-string auth)
3 🟡 Medium SSRF guard bypass via exported test hook _setFetchGuardForTesting
4 🔵 Low Plaintext BlueBubbles password stored in global client-cache fingerprint string
1. 🟠 SSRF guard bypass via `allowedHostnames` disabling private-IP/DNS-rebinding checks for BlueBubbles server hostname
Property Value
Severity High
CWE CWE-918
Location extensions/bluebubbles/src/client.ts:134-143

Description

resolveBlueBubblesClientSsrfPolicy returns ssrfPolicy: { allowedHostnames: [trustedHostname] } for any parseable hostname when allowPrivateNetwork is false (unless the user explicitly opted out and the hostname is detected as private).

In fetchWithSsrFGuard/ssrf.ts, policy.allowedHostnames causes shouldSkipPrivateNetworkChecks() to return true, which skips both:

  • pre-DNS checks for literal private IPs/hostnames
  • post-DNS checks that block hostnames that resolve to private/internal/special-use IPs (the DNS rebinding/CNAME pivot protection)

As a result, a configured BlueBubbles hostname that is allowlisted via allowedHostnames can resolve (or later rebind) to private/internal targets (e.g., cloud metadata 169.254.169.254, RFC1918, loopback, etc.) and still be fetched through the guarded path.

Vulnerable code:

if (trustedHostname && (params.allowPrivateNetworkConfig !== false || !trustedHostnameIsPrivate)) {
  return { ssrfPolicy: { allowedHostnames: [trustedHostname] }, ... };
}

This weakens SSRF protections for the common case where serverUrl is a normal public DNS name (e.g., bb.example.com), because allowlisting it via allowedHostnames disables the “public hostname must not resolve to private IP” control.

Recommendation

Do not use allowedHostnames for general hostname allowlisting, because in the SSRF guard it also disables private-network/DNS-answer checks.

Instead:

  • Use hostnameAllowlist: [trustedHostname] to restrict requests to that hostname while still enforcing that DNS answers are public/global.
  • Only use allowedHostnames: [trustedHostname] when you intentionally want to allow private resolution for that hostname (e.g., when trustedHostnameIsPrivate === true and the user has opted in to that behavior).

Example fix:

if (params.allowPrivateNetwork) {
  return { ssrfPolicy: { allowPrivateNetwork: true }, trustedHostname, trustedHostnameIsPrivate };
}

if (trustedHostname) {// Always constrain to the configured hostname
  const hostnameAllowlist = [trustedHostname];

  if (trustedHostnameIsPrivate && params.allowPrivateNetworkConfig !== false) {// Explicitly allow private targets for the configured private hostname
    return {
      ssrfPolicy: { hostnameAllowlist, allowedHostnames: [trustedHostname] },
      trustedHostname,
      trustedHostnameIsPrivate,
    };
  }// Public hostname: allow only this hostname, but keep DNS/private-IP blocking
  return {
    ssrfPolicy: { hostnameAllowlist },
    trustedHostname,
    trustedHostnameIsPrivate,
  };
}

return { ssrfPolicy: {}, trustedHostname, trustedHostnameIsPrivate };

Add tests ensuring that with a public trustedHostname, the guard still blocks DNS answers to private IP ranges (DNS rebinding protection stays active).

2. 🟡 BlueBubbles API password appended to request URLs by default (query-string auth)
Property Value
Severity Medium
CWE CWE-598
Location extensions/bluebubbles/src/client.ts:55-60

Description

The new consolidated BlueBubblesClient continues to use query-string authentication by default, appending the BlueBubbles API password as ?password=... on every request.

This is a credential leakage risk because URLs are commonly:

  • recorded in server/proxy/CDN access logs and application logs
  • captured by debugging/telemetry tooling
  • stored in browser/network caches and intermediary caches
  • potentially exposed via redirect chains (the initial request URL is still sent on the wire and may be logged)

In this change, the client centralization makes this behavior more consistent across code paths (including attachment downloads and multipart), increasing the likelihood that the password appears in logs.

Vulnerable code:

export function blueBubblesQueryStringAuth(password: string): BlueBubblesAuthStrategy {
  return {
    id: "query-string",
    decorate({ url }) {
      url.searchParams.set("password", password);
    },
  };
}// default:
const authFactory = opts.authStrategy ?? blueBubblesQueryStringAuth;

Recommendation

Prefer header-based authentication so secrets are not placed in URLs.

  • Switch the factory default to blueBubblesHeaderAuth once the server supports it.
  • In the meantime, consider making query-string auth opt-in (explicitly requested), and add redaction where URLs may be logged.

Example safer default:

export function createBlueBubblesClient(opts: BlueBubblesClientOptions = {}): BlueBubblesClient {// ...resolve baseUrl/password...
  const authFactory = opts.authStrategy ?? blueBubblesHeaderAuth;
  const authStrategy = authFactory(resolved.password);// ...
}

If query-string auth must remain for compatibility, add a redaction helper before any logging/telemetry of request URLs (e.g., replace password param with ***).

3. 🟡 SSRF guard bypass via exported test hook `_setFetchGuardForTesting`
Property Value
Severity Medium
CWE CWE-285
Location extensions/bluebubbles/src/types.ts:141-147

Description

extensions/bluebubbles/src/types.ts exports a mutable test hook that allows replacing the SSRF-guarded fetch implementation used by blueBubblesFetchWithTimeout.

  • _fetchGuard defaults to fetchWithSsrFGuard (the SSRF protection)
  • Any code that can import ./types.js can call _setFetchGuardForTesting(...) to swap in an unguarded implementation
  • Subsequent BlueBubbles API calls that provide an ssrfPolicy will route through the attacker-controlled _fetchGuard, effectively disabling SSRF enforcement for those requests

Vulnerable code:

// Overridable guard for testing; production code uses fetchWithSsrFGuard.
let _fetchGuard = fetchWithSsrFGuard;/** @​internal Replace the SSRF fetch guard in tests. */
export function _setFetchGuardForTesting(impl: typeof fetchWithSsrFGuard | null): void {
  _fetchGuard = impl ?? fetchWithSsrFGuard;
}

While intended for tests (and used by test-harness.ts/tests), this is part of the production module surface and is not gated to test-only builds. In environments where multiple extensions/plugins run in the same process (or where untrusted code can execute), this enables runtime SSRF protection bypass by mutating the guard globally for the BlueBubbles extension.

Recommendation

Remove this hook from production builds or strictly gate it to test environments.

Options:

  1. Move the hook to a test-only module and avoid exporting it from types.ts.

  2. Gate by environment so it cannot be used in production:

let _fetchGuard = fetchWithSsrFGuard;

export function _setFetchGuardForTesting(impl: typeof fetchWithSsrFGuard | null): void {
  if (process.env.NODE_ENV !== "test") {
    throw new Error("_setFetchGuardForTesting is test-only");
  }
  _fetchGuard = impl ?? fetchWithSsrFGuard;
}
  1. If you must keep it, do not export it from public entrypoints and ensure your bundler/tree-shaking excludes it from release artifacts.
4. 🔵 Plaintext BlueBubbles password stored in global client-cache fingerprint string
Property Value
Severity Low
CWE CWE-522
Location extensions/bluebubbles/src/client.ts:466-475

Description

buildClientFingerprint() concatenates the BlueBubbles server password into a cache fingerprint string that is stored in a module-global Map (clientFingerprints).

  • The fingerprint is long-lived (until invalidateBlueBubblesClient()/clearBlueBubblesClientCache()), so the password remains duplicated in memory longer than necessary.
  • The fingerprint value is easy to accidentally expose via debugging, heap snapshots, crash reports, or by logging the cached entry.

Vulnerable code:

return `${params.baseUrl}|${params.password}|${params.authStrategyId}`;

Recommendation

Avoid storing plaintext credentials in cache keys/values.

Use a one-way hash (or keyed HMAC) of the tuple instead of embedding the password directly:

import { createHash } from "node:crypto";

function buildClientFingerprint(params: { baseUrl: string; password: string; authStrategyId: string }): string {
  const raw = `${params.baseUrl}|${params.password}|${params.authStrategyId}`;
  return createHash("sha256").update(raw, "utf8").digest("hex");
}

Optionally, consider:

  • hashing only the password (e.g., sha256(password)) and keeping the rest in clear, or
  • storing { baseUrl, authStrategyId } in the cached entry and comparing the provided password directly without concatenating it into a single string.

Analyzed PR: #68234 at commit ee72657

Last updated on: 2026-04-19T15:46:15Z

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: 6dfb043d80

ℹ️ 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 thread extensions/bluebubbles/src/send.ts
omarshahine added a commit that referenced this pull request Apr 18, 2026
…(aisle #68234 HIGH)

Aisle security analysis flagged `resolveBlueBubblesClientSsrfPolicy` mode 3
as an SSRF bypass: it returned `ssrfPolicy: undefined`, which
`blueBubblesFetchWithTimeout` treats as a signal to skip
`fetchWithSsrFGuard` and call `fetch()` directly.

Mode 3 fires in two cases:
  A. Private hostname + user explicitly opted out
     (`network.dangerouslyAllowPrivateNetwork: false`). The user asked
     us to block private networks and we instead sent the request via
     the unguarded fallback path — a real bypass.
  B. Unparseable baseUrl. Would route through the unguarded path
     (cosmetic in practice since the fetch would fail elsewhere, but
     still wrong).

Fix: return `ssrfPolicy: {}` (default-deny guarded policy) instead of
`undefined`. All three modes now go through `fetchWithSsrFGuard`:

  1. `{ allowPrivateNetwork: true }`   — user opted in
  2. `{ allowedHostnames: [host] }`   — narrow allowlist for trusted host
  3. `{}`                              — default-deny, honors opt-out

Tightened `ssrfPolicy` field/param types from `SsrFPolicy | undefined`
to `SsrFPolicy` so the bypass cannot be reintroduced by a future caller
handing back `undefined`.

Added a mode-3 invariant regression test that walks every resolution
case and asserts `ssrfPolicy` is always defined (caught any future
`undefined` reintroduction). Updated `attachments.test.ts` case that
was asserting the old (buggy) `undefined` behavior.

Existing-user impact: only users with
`dangerouslyAllowPrivateNetwork: false` set on a private-address BB
server see a behavior change (they now get a guarded refusal instead of
an accidental success). That's the correct posture per their config.
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: 3ef012f20c

ℹ️ 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 thread extensions/bluebubbles/src/probe.ts
omarshahine added a commit that referenced this pull request Apr 19, 2026
…ache fingerprint)

Greptile findings from the first review cycle on #68234 — both real, both
asymptomatic under today's query-string auth default but blocking for the
header-auth flip (#66869).

P1: Thread auth-decorated headers through multipart + media paths.
- `postMultipartFormData` now accepts an optional `extraHeaders` param and
  merges caller headers with its own Content-Type (Content-Type always wins
  so the multipart boundary stays authoritative).
- `client.requestMultipart` forwards `prepared.init.headers` — attachment
  uploads and group-icon sets keep working under header auth.
- `client.downloadAttachment` fetchImpl merges `prepared.init.headers` with
  runtime-supplied headers (runtime wins on conflict, e.g. Range for partial
  reads) — attachment downloads via `fetchRemoteMedia` now carry auth too.

P2: Include auth strategy in the client cache fingerprint.
- `BlueBubblesAuthStrategy` gets a stable `id` field
  (`query-string` / `header:<name>`).
- Built-in factories set it; the cache key is now
  `{baseUrl}|{password}|{authStrategyId}` so different strategies for the
  same account + credential no longer silently share a cached instance.

Three new regression tests pin the above behavior (465 total, was 462).
Full BB suite green, lint clean.
@omarshahine omarshahine force-pushed the feat/bluebubbles-client-introduce branch from 33b8827 to 81b0ae1 Compare April 19, 2026 06:45
omarshahine added a commit that referenced this pull request Apr 19, 2026
…(aisle #68234 HIGH)

Aisle security analysis flagged `resolveBlueBubblesClientSsrfPolicy` mode 3
as an SSRF bypass: it returned `ssrfPolicy: undefined`, which
`blueBubblesFetchWithTimeout` treats as a signal to skip
`fetchWithSsrFGuard` and call `fetch()` directly.

Mode 3 fires in two cases:
  A. Private hostname + user explicitly opted out
     (`network.dangerouslyAllowPrivateNetwork: false`). The user asked
     us to block private networks and we instead sent the request via
     the unguarded fallback path — a real bypass.
  B. Unparseable baseUrl. Would route through the unguarded path
     (cosmetic in practice since the fetch would fail elsewhere, but
     still wrong).

Fix: return `ssrfPolicy: {}` (default-deny guarded policy) instead of
`undefined`. All three modes now go through `fetchWithSsrFGuard`:

  1. `{ allowPrivateNetwork: true }`   — user opted in
  2. `{ allowedHostnames: [host] }`   — narrow allowlist for trusted host
  3. `{}`                              — default-deny, honors opt-out

Tightened `ssrfPolicy` field/param types from `SsrFPolicy | undefined`
to `SsrFPolicy` so the bypass cannot be reintroduced by a future caller
handing back `undefined`.

Added a mode-3 invariant regression test that walks every resolution
case and asserts `ssrfPolicy` is always defined (caught any future
`undefined` reintroduction). Updated `attachments.test.ts` case that
was asserting the old (buggy) `undefined` behavior.

Existing-user impact: only users with
`dangerouslyAllowPrivateNetwork: false` set on a private-address BB
server see a behavior change (they now get a guarded refusal instead of
an accidental success). That's the correct posture per their config.
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: 81b0ae1121

ℹ️ 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 +432 to +436
return await blueBubblesFetchWithTimeout(
resolveRequestUrl(input),
{ ...init, method: init?.method ?? "GET", headers: mergedHeaders },
effectiveTimeoutMs,
clientSsrfPolicy,
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve streaming in attachment download fetch callback

Passing clientSsrfPolicy here forces blueBubblesFetchWithTimeout onto its guarded branch, which buffers the entire response body before returning (response.arrayBuffer() in types.ts). In this call path, fetchRemoteMedia is supposed to enforce maxBytes while streaming, so pre-buffering defeats that protection for large/chunked attachments and can cause avoidable memory spikes or OOMs before the size limit is enforced.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

😃

@nowdaysmen-sudo
Copy link
Copy Markdown

.

Closes #34749, closes #59722.

All outbound BlueBubbles HTTP calls now route through a single typed
BlueBubblesClient that resolves SSRF policy and auth material once
at construction, instead of each of the 17 callsites computing its
own SsrFPolicy and threading it through blueBubblesFetchWithTimeout.

Three-mode policy resolved in one place (previously inlined in
attachments.ts only):
  1. { allowPrivateNetwork: true }       — user opted in
  2. { allowedHostnames: [trustedHost] } — narrow allowlist
  3. undefined                           — non-SSRF fallback path

Pluggable auth via BlueBubblesAuthStrategy so the ?password= →
header-auth switch (#66869) becomes a one-line default flip.

downloadAttachment threads this.ssrfPolicy to BOTH fetchRemoteMedia
AND the fetchImpl callback, closing #34749 where the legacy helper
silently omitted the policy on the callback path. reactions.ts now
uses the same policy as every other call, closing #59722 where the
asymmetric `{}` fallback blocked private-IP BB deployments.

Monitor test files install the shared SSRF-guard passthrough to
accommodate the guarded request path — required because the
consolidation moves callers from the unguarded fallback onto the
mode-2 allowlist by default for self-hosted BB servers.

Net: +171 / -310 LOC across BB files; BB test suite stays green
at 462/462. Lint clean. pnpm tsgo and pnpm build have pre-existing
unrelated errors on origin/main (codex/discord/qa-lab and
amazon-bedrock-mantle runtime-deps staging) — both identical on clean
main before this branch.

Future-proofed (not flipped here):
- #66869 header auth cutover (one-line default change)
- #67752 SSRF tightening (now single-point-of-change)
…ache fingerprint)

Greptile findings from the first review cycle on #68234 — both real, both
asymptomatic under today's query-string auth default but blocking for the
header-auth flip (#66869).

P1: Thread auth-decorated headers through multipart + media paths.
- `postMultipartFormData` now accepts an optional `extraHeaders` param and
  merges caller headers with its own Content-Type (Content-Type always wins
  so the multipart boundary stays authoritative).
- `client.requestMultipart` forwards `prepared.init.headers` — attachment
  uploads and group-icon sets keep working under header auth.
- `client.downloadAttachment` fetchImpl merges `prepared.init.headers` with
  runtime-supplied headers (runtime wins on conflict, e.g. Range for partial
  reads) — attachment downloads via `fetchRemoteMedia` now carry auth too.

P2: Include auth strategy in the client cache fingerprint.
- `BlueBubblesAuthStrategy` gets a stable `id` field
  (`query-string` / `header:<name>`).
- Built-in factories set it; the cache key is now
  `{baseUrl}|{password}|{authStrategyId}` so different strategies for the
  same account + credential no longer silently share a cached instance.

Three new regression tests pin the above behavior (465 total, was 462).
Full BB suite green, lint clean.
…(aisle #68234 HIGH)

Aisle security analysis flagged `resolveBlueBubblesClientSsrfPolicy` mode 3
as an SSRF bypass: it returned `ssrfPolicy: undefined`, which
`blueBubblesFetchWithTimeout` treats as a signal to skip
`fetchWithSsrFGuard` and call `fetch()` directly.

Mode 3 fires in two cases:
  A. Private hostname + user explicitly opted out
     (`network.dangerouslyAllowPrivateNetwork: false`). The user asked
     us to block private networks and we instead sent the request via
     the unguarded fallback path — a real bypass.
  B. Unparseable baseUrl. Would route through the unguarded path
     (cosmetic in practice since the fetch would fail elsewhere, but
     still wrong).

Fix: return `ssrfPolicy: {}` (default-deny guarded policy) instead of
`undefined`. All three modes now go through `fetchWithSsrFGuard`:

  1. `{ allowPrivateNetwork: true }`   — user opted in
  2. `{ allowedHostnames: [host] }`   — narrow allowlist for trusted host
  3. `{}`                              — default-deny, honors opt-out

Tightened `ssrfPolicy` field/param types from `SsrFPolicy | undefined`
to `SsrFPolicy` so the bypass cannot be reintroduced by a future caller
handing back `undefined`.

Added a mode-3 invariant regression test that walks every resolution
case and asserts `ssrfPolicy` is always defined (caught any future
`undefined` reintroduction). Updated `attachments.test.ts` case that
was asserting the old (buggy) `undefined` behavior.

Existing-user impact: only users with
`dangerouslyAllowPrivateNetwork: false` set on a private-address BB
server see a behavior change (they now get a guarded refusal instead of
an accidental success). That's the correct posture per their config.
@omarshahine omarshahine force-pushed the feat/bluebubbles-client-introduce branch from 81b0ae1 to ee72657 Compare April 19, 2026 15:43
@omarshahine omarshahine merged commit 055c17b into main Apr 19, 2026
10 checks passed
@omarshahine omarshahine deleted the feat/bluebubbles-client-introduce branch April 19, 2026 15:43
@omarshahine
Copy link
Copy Markdown
Contributor Author

Merged via squash.

Thanks @omarshahine!

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: ee72657bc8

ℹ️ 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 +92 to +96
const client = createBlueBubblesClientFromParts({
baseUrl,
password,
allowPrivateNetwork,
timeoutMs: opts.timeoutMs ?? 10000,
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve explicit private-network opt-out in history fetch

Fresh evidence beyond the existing send/probe thread: this migration switches history reads from an explicit {} SSRF policy to createBlueBubblesClientFromParts while only passing allowPrivateNetwork. In the network.dangerouslyAllowPrivateNetwork: false + private-host case, allowPrivateNetworkConfig becomes undefined, so resolveBlueBubblesClientSsrfPolicy takes the allowlist branch and still permits calls to the private BlueBubbles host; the pre-change code in this function used {} and blocked that path. This means an operator’s explicit opt-out is not enforced for history requests (and the same pattern appears in catchup).

Useful? React with 👍 / 👎.

Mquarmoc pushed a commit to Mquarmoc/openclaw that referenced this pull request Apr 20, 2026
…openclaw#68234)

Merged via squash.

Prepared head SHA: ee72657
Co-authored-by: omarshahine <10343873+omarshahine@users.noreply.github.com>
Co-authored-by: omarshahine <10343873+omarshahine@users.noreply.github.com>
Reviewed-by: @omarshahine
lovewanwan pushed a commit to lovewanwan/openclaw that referenced this pull request Apr 28, 2026
…openclaw#68234)

Merged via squash.

Prepared head SHA: ee72657
Co-authored-by: omarshahine <10343873+omarshahine@users.noreply.github.com>
Co-authored-by: omarshahine <10343873+omarshahine@users.noreply.github.com>
Reviewed-by: @omarshahine
omarshahine pushed a commit to coletebou/openclaw that referenced this pull request May 1, 2026
…nt merge, dead code)

- Route reply-context fetch through resolveBlueBubblesClientSsrfPolicy so
  the SSRF guard is always engaged. The three-mode resolver never returns
  undefined; previously we passed undefined when private-network opt-in was
  off, which silently skipped fetchWithSsrFGuard. Same bug class as openclaw#68234.
- Drop .default(false) from per-account replyContextApiFallback so accounts
  that omit the flag inherit the channel-level value through
  mergeAccountConfig instead of clobbering it with false.
- Remove redundant if (body || sender) guard left after the early-return
  on the previous line.
- Add three-mode SSRF coverage in monitor-reply-fetch.test.ts (mode 1
  opt-in, mode 2 hostname allowlist, mode 3 default-deny) plus an explicit
  regression test, and end-to-end inheritance tests in setup-surface.test.ts
  through resolveBlueBubblesAccount.
ogt-redknie pushed a commit to ogt-redknie/OPENX that referenced this pull request May 2, 2026
…openclaw#68234)

Merged via squash.

Prepared head SHA: ee72657
Co-authored-by: omarshahine <10343873+omarshahine@users.noreply.github.com>
Co-authored-by: omarshahine <10343873+omarshahine@users.noreply.github.com>
Reviewed-by: @omarshahine
github-actions Bot pushed a commit to Desicool/openclaw that referenced this pull request May 9, 2026
…openclaw#68234)

Merged via squash.

Prepared head SHA: ee72657
Co-authored-by: omarshahine <10343873+omarshahine@users.noreply.github.com>
Co-authored-by: omarshahine <10343873+omarshahine@users.noreply.github.com>
Reviewed-by: @omarshahine
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: XL

Projects

None yet

2 participants