fix(bluebubbles): strip SSRF dispatcher from fetchImpl to fix attachment downloads#63984
fix(bluebubbles): strip SSRF dispatcher from fetchImpl to fix attachment downloads#63984coletebou wants to merge 1 commit intoopenclaw:mainfrom
Conversation
…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.
There was a problem hiding this comment.
💡 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>) ?? {}; |
There was a problem hiding this comment.
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 SummaryThis PR fixes a complete attachment download failure on Node 24+ BlueBubbles deployments: the SSRF guard was injecting a bundled undici 8.x
Confidence Score: 4/5Safe 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.
|
| 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, | ||
| ), | ||
| ); | ||
| }, |
There was a problem hiding this 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.
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.| 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, |
There was a problem hiding this 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:
| 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.|
Same root cause in the Slack extension — submitted #64022 with the equivalent fix for |
|
The merged Slack fix (#62239) solved this same problem without the DNS-pinning regression — instead of stripping 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 |
…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
|
Closing in favor of a cleaner approach that uses fetchWithRuntimeDispatcher (matching the Slack fix #62239) instead of stripping the dispatcher. New PR incoming. |
Problem
BlueBubbles attachment downloads silently fail with
invalid onRequestStart methoderrors. 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
initobject to the BlueBubblesfetchImplwrapper.blueBubblesFetchWithTimeoutdelegates to Node's built-infetch(), 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
dispatcherproperty from theinitobject before passing it toblueBubblesFetchWithTimeout. 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.