Skip to content

fix(bluebubbles): strip SSRF dispatcher from fetchImpl to fix attachment downloads#63984

Closed
coletebou wants to merge 1 commit intoopenclaw:mainfrom
coletebou:fix/bluebubbles-undici-dispatcher-mismatch
Closed

fix(bluebubbles): strip SSRF dispatcher from fetchImpl to fix attachment downloads#63984
coletebou wants to merge 1 commit intoopenclaw:mainfrom
coletebou:fix/bluebubbles-undici-dispatcher-mismatch

Conversation

@coletebou
Copy link
Copy Markdown
Contributor

Problem

BlueBubbles attachment downloads silently fail with invalid onRequestStart method errors. No attachments (images, PDFs, files) are ever downloaded despite valid GUIDs and server connectivity.

Root Cause

The SSRF guard creates a pinned undici Agent (dispatcher) using the bundled undici 8.x and passes it through the init object to the BlueBubbles fetchImpl wrapper. blueBubblesFetchWithTimeout delegates to Node's built-in fetch(), which uses Node 24's built-in undici 7.x. The undici 8 Agent's interceptor API (onRequestStart) is incompatible with undici 7's fetch, causing every download to throw.

Fix

Strip the dispatcher property from the init object before passing it to blueBubblesFetchWithTimeout. This lets Node's built-in fetch handle the request with its own connection management while the SSRF hostname validation still runs upstream (the DNS pinning check happens before the fetch call).

Impact

Affects any OpenClaw deployment using BlueBubbles on Node 24+ where the bundled undici version differs from Node's built-in one.

…ent downloads

The SSRF guard creates a pinned undici Agent (dispatcher) and passes it
through the init object to the BlueBubbles fetchImpl wrapper.
blueBubblesFetchWithTimeout delegates to Node's built-in fetch, which uses
a different undici major version (7.x) than the bundled one (8.x). The
version mismatch causes 'invalid onRequestStart method' errors on every
attachment download attempt, silently dropping all inbound media.

Strip the dispatcher from init before passing to the timeout wrapper,
letting Node's built-in fetch handle the request without the incompatible
dispatcher.
@openclaw-barnacle openclaw-barnacle Bot added channel: bluebubbles Channel integration: bluebubbles size: XS labels Apr 10, 2026
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: ef0a5cf31c

ℹ️ About Codex in GitHub

Your team has set up Codex to 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 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

// dispatcher through causes "invalid onRequestStart method" errors when
// the bundled undici major version differs from Node's built-in one.
fetchImpl: async (input, init) => {
const { dispatcher: _d, ...safeInit } = (init as Record<string, unknown>) ?? {};
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 pinned dispatcher in guarded attachment fetch

Stripping dispatcher here defeats the DNS-pinning guarantee that fetchRemoteMedia/fetchWithSsrFGuard is designed to provide (src/media/fetch.ts and src/infra/net/fetch-guard.ts resolve and pin before issuing the request). In this BlueBubbles path, ssrfPolicy commonly uses allowedHostnames, which skips private-network checks during resolution (src/infra/net/ssrf.ts), so if the trusted hostname is DNS-rebound after the guard lookup, Node’s built-in fetch can re-resolve to a different (including internal) IP because the pinned dispatcher was removed. That is a security regression introduced by this change.

Useful? React with 👍 / 👎.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Apr 10, 2026

Greptile Summary

This PR fixes a complete attachment download failure on Node 24+ BlueBubbles deployments: the SSRF guard was injecting a bundled undici 8.x Agent as dispatcher into init, which then crashed Node's built-in fetch (undici 7.x) with invalid onRequestStart method. The fix strips dispatcher before forwarding init to blueBubblesFetchWithTimeout.

  • The SSRF hostname validation (resolvePinnedHostnameWithPolicy) still runs before fetchImpl is called, so host-allowlist and private-network checks are preserved.
  • However, stripping the pinned dispatcher means Node's fetch performs a fresh DNS lookup at connection time, removing the DNS-rebinding protection the pinned dispatcher was designed to provide. For typical BlueBubbles (LAN) deployments the risk is low, but it is a genuine regression in the SSRF guard's defence-in-depth.

Confidence Score: 4/5

Safe to merge for fixing the crash, but the DNS-rebinding regression in the SSRF guard warrants acknowledgment before landing.

The fix correctly resolves a complete attachment download failure. The one concern worth addressing is that stripping the pinned dispatcher removes DNS-rebinding protection — a real (if low-risk for LAN BlueBubbles setups) security regression relative to the guard's design. A style cleanup on the redundant casts is also suggested but non-blocking.

extensions/bluebubbles/src/attachments.ts — specifically the fetchImpl lambda and how it interacts with the SSRF guard's dispatcher lifecycle.

Security Review

  • DNS rebinding window opened (extensions/bluebubbles/src/attachments.ts): stripping the dispatcher from init removes the pinned-IP connection that the SSRF guard established after hostname validation. Node's built-in fetch performs a fresh DNS resolution, creating a window where a DNS rebinding attack could redirect the attachment download to a private/blocked address. Risk is low in typical BlueBubbles (LAN) deployments but is a real regression relative to the SSRF guard's design intent.

Fix All in Codex Fix All in Claude Code

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

Comment:
**DNS pinning bypassed by stripping dispatcher**

Stripping `dispatcher` fixes the undici compatibility crash, but the pinned dispatcher exists specifically to prevent DNS rebinding: it resolves the hostname, validates the IP, then pins the TCP connection to that exact IP so a mid-flight DNS change can't redirect the request to a private/blocked address. After this change, Node's built-in `fetch` does a fresh DNS lookup at connection time, making the validated-but-not-yet-connected window susceptible to rebinding.

In practice the risk is low for BlueBubbles (user-owned server, typically LAN), but it is a real security regression relative to the intent of the SSRF guard. A safer approach is to pass a `lookupFn` (or an `allowedHostnames`-scoped hostname check) that `blueBubblesFetchWithTimeout` can honour, or to open a seam in `blueBubblesFetchWithTimeout` that accepts a pre-resolved IP so the guard's resolution result can be reused without injecting an undici dispatcher.

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/attachments.ts
Line: 132-138

Comment:
**Redundant casts and optional chain on a known object**

`safeInit` is already `Record<string, unknown>` (TypeScript infers this from the rest-spread of a `Record<string, unknown>`), so the `as Record<string, unknown>` re-assertion on line 137 and the `?.` optional chain are both no-ops. The final `as RequestInit` assertion is also unverified — any extra unknown keys survive the spread. A narrower, explicit pick would be safer:

```suggestion
        const { dispatcher: _d, ...safeInit } = (init ?? {}) as Record<string, unknown>;
        return await blueBubblesFetchWithTimeout(
          resolveRequestUrl(input),
          {
            ...safeInit,
            method: (safeInit.method as string | undefined) ?? "GET",
          } as RequestInit,
          opts.timeoutMs,
        );
```

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

Reviews (1): Last reviewed commit: "fix(bluebubbles): strip SSRF dispatcher ..." | Re-trigger Greptile

Comment on lines +131 to +141
fetchImpl: async (input, init) => {
const { dispatcher: _d, ...safeInit } = (init as Record<string, unknown>) ?? {};
return await blueBubblesFetchWithTimeout(
resolveRequestUrl(input),
{ ...init, method: init?.method ?? "GET" },
{
...safeInit,
method: ((safeInit as Record<string, unknown>)?.method as string) ?? "GET",
} as RequestInit,
opts.timeoutMs,
),
);
},
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 security DNS pinning bypassed by stripping dispatcher

Stripping dispatcher fixes the undici compatibility crash, but the pinned dispatcher exists specifically to prevent DNS rebinding: it resolves the hostname, validates the IP, then pins the TCP connection to that exact IP so a mid-flight DNS change can't redirect the request to a private/blocked address. After this change, Node's built-in fetch does a fresh DNS lookup at connection time, making the validated-but-not-yet-connected window susceptible to rebinding.

In practice the risk is low for BlueBubbles (user-owned server, typically LAN), but it is a real security regression relative to the intent of the SSRF guard. A safer approach is to pass a lookupFn (or an allowedHostnames-scoped hostname check) that blueBubblesFetchWithTimeout can honour, or to open a seam in blueBubblesFetchWithTimeout that accepts a pre-resolved IP so the guard's resolution result can be reused without injecting an undici dispatcher.

Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/bluebubbles/src/attachments.ts
Line: 131-141

Comment:
**DNS pinning bypassed by stripping dispatcher**

Stripping `dispatcher` fixes the undici compatibility crash, but the pinned dispatcher exists specifically to prevent DNS rebinding: it resolves the hostname, validates the IP, then pins the TCP connection to that exact IP so a mid-flight DNS change can't redirect the request to a private/blocked address. After this change, Node's built-in `fetch` does a fresh DNS lookup at connection time, making the validated-but-not-yet-connected window susceptible to rebinding.

In practice the risk is low for BlueBubbles (user-owned server, typically LAN), but it is a real security regression relative to the intent of the SSRF guard. A safer approach is to pass a `lookupFn` (or an `allowedHostnames`-scoped hostname check) that `blueBubblesFetchWithTimeout` can honour, or to open a seam in `blueBubblesFetchWithTimeout` that accepts a pre-resolved IP so the guard's resolution result can be reused without injecting an undici dispatcher.

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

Fix in Codex Fix in Claude Code

Comment on lines +132 to +138
const { dispatcher: _d, ...safeInit } = (init as Record<string, unknown>) ?? {};
return await blueBubblesFetchWithTimeout(
resolveRequestUrl(input),
{ ...init, method: init?.method ?? "GET" },
{
...safeInit,
method: ((safeInit as Record<string, unknown>)?.method as string) ?? "GET",
} as RequestInit,
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 Redundant casts and optional chain on a known object

safeInit is already Record<string, unknown> (TypeScript infers this from the rest-spread of a Record<string, unknown>), so the as Record<string, unknown> re-assertion on line 137 and the ?. optional chain are both no-ops. The final as RequestInit assertion is also unverified — any extra unknown keys survive the spread. A narrower, explicit pick would be safer:

Suggested change
const { dispatcher: _d, ...safeInit } = (init as Record<string, unknown>) ?? {};
return await blueBubblesFetchWithTimeout(
resolveRequestUrl(input),
{ ...init, method: init?.method ?? "GET" },
{
...safeInit,
method: ((safeInit as Record<string, unknown>)?.method as string) ?? "GET",
} as RequestInit,
const { dispatcher: _d, ...safeInit } = (init ?? {}) as Record<string, unknown>;
return await blueBubblesFetchWithTimeout(
resolveRequestUrl(input),
{
...safeInit,
method: (safeInit.method as string | undefined) ?? "GET",
} as RequestInit,
opts.timeoutMs,
);
Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/bluebubbles/src/attachments.ts
Line: 132-138

Comment:
**Redundant casts and optional chain on a known object**

`safeInit` is already `Record<string, unknown>` (TypeScript infers this from the rest-spread of a `Record<string, unknown>`), so the `as Record<string, unknown>` re-assertion on line 137 and the `?.` optional chain are both no-ops. The final `as RequestInit` assertion is also unverified — any extra unknown keys survive the spread. A narrower, explicit pick would be safer:

```suggestion
        const { dispatcher: _d, ...safeInit } = (init ?? {}) as Record<string, unknown>;
        return await blueBubblesFetchWithTimeout(
          resolveRequestUrl(input),
          {
            ...safeInit,
            method: (safeInit.method as string | undefined) ?? "GET",
          } as RequestInit,
          opts.timeoutMs,
        );
```

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

Fix in Codex Fix in Claude Code

@mjamiv
Copy link
Copy Markdown
Contributor

mjamiv commented Apr 10, 2026

Same root cause in the Slack extension — submitted #64022 with the equivalent fix for createSlackMediaFetch(). This dispatcher version collision pattern likely affects any extension that receives init from fetchWithSsrFGuard and delegates to a different fetch path.

@meimakes
Copy link
Copy Markdown

The merged Slack fix (#62239) solved this same problem without the DNS-pinning regression — instead of stripping dispatcher, it routes through fetchWithRuntimeDispatcher (bundled undici's fetch) which is compatible with the bundled undici's dispatcher:

fetchImpl: async (input, init) => {
  const useFetch = init && "dispatcher" in init
    ? fetchWithRuntimeDispatcher
    : globalThis.fetch;
  return await useFetch(
    resolveRequestUrl(input),
    { ...init, method: init?.method ?? "GET" },
  );
},

This closes the rebinding window that the Greptile review flagged — the dispatcher stays intact so the SSRF guard's pinned connection is preserved. I hit this bug on a BlueBubbles + Node 25.6.0 setup and confirmed this approach works (attachments download, DNS pinning intact).

Also makes the redundant casts a non-issue since init passes through unmodified.

mjamiv pushed a commit to mjamiv/openclaw that referenced this pull request Apr 13, 2026
…ndici version conflict

When `fetchWithSsrFGuard` delegates to `createSlackMediaFetch`, it injects a
pinned undici 8.x dispatcher into the `init` object. `createSlackMediaFetch`
then spreads `...init` (including that dispatcher) into
`fetchWithRuntimeDispatcher`, which creates its own dispatcher. The two
dispatchers from different undici versions collide, causing "invalid
onRequestStart method" errors that silently drop all inbound Slack attachments.

Fix: use the presence of `dispatcher` in `init` as a routing signal (runtime
fetch vs global fetch) but strip it before forwarding. This matches the
approach taken in openclaw#63984 (BlueBubbles) for the same root cause.

Fixes openclaw#63905
@coletebou
Copy link
Copy Markdown
Contributor Author

Closing in favor of a cleaner approach that uses fetchWithRuntimeDispatcher (matching the Slack fix #62239) instead of stripping the dispatcher. New PR incoming.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

channel: bluebubbles Channel integration: bluebubbles size: XS

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants