diff --git a/CHANGELOG.md b/CHANGELOG.md index 21abc031d..ea84f62e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Pi image-token usage: stop re-injecting history image blocks each turn, process image references from the current prompt only, and prune already-answered user-image blocks in stored history to prevent runaway token growth. (#27602) +- BlueBubbles/SSRF: auto-allowlist the configured `serverUrl` hostname for attachment fetches so localhost/private-IP BlueBubbles setups are no longer false-blocked by default SSRF checks. Landed from contributor PR #27648 by @lailoo. (#27599) Thanks @taylorhou for reporting. - Security/Gateway node pairing: pin paired-device `platform`/`deviceFamily` metadata across reconnects and bind those fields into device-auth signatures, so reconnect metadata spoofing cannot expand node command allowlists without explicit repair pairing. This ships in the next npm release (`2026.2.26`). Thanks @76embiid21 for reporting. - Security/Sandbox path alias guard: reject broken symlink targets by resolving through existing ancestors and failing closed on out-of-root targets, preventing workspace-only `apply_patch` writes from escaping sandbox/workspace boundaries via dangling symlinks. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting. - Security/Workspace FS boundary aliases: harden canonical boundary resolution for non-existent-leaf symlink aliases while preserving valid in-root aliases, preventing first-write workspace escapes via out-of-root symlink targets. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting. diff --git a/extensions/bluebubbles/src/attachments.test.ts b/extensions/bluebubbles/src/attachments.test.ts index d6b12d311..da431c732 100644 --- a/extensions/bluebubbles/src/attachments.test.ts +++ b/extensions/bluebubbles/src/attachments.test.ts @@ -294,7 +294,7 @@ describe("downloadBlueBubblesAttachment", () => { expect(fetchMediaArgs.ssrfPolicy).toEqual({ allowPrivateNetwork: true }); }); - it("does not pass ssrfPolicy when allowPrivateNetwork is not set", async () => { + it("auto-allowlists serverUrl hostname when allowPrivateNetwork is not set", async () => { const mockBuffer = new Uint8Array([1]); mockFetch.mockResolvedValueOnce({ ok: true, @@ -309,7 +309,25 @@ describe("downloadBlueBubblesAttachment", () => { }); const fetchMediaArgs = fetchRemoteMediaMock.mock.calls[0][0] as Record; - expect(fetchMediaArgs.ssrfPolicy).toBeUndefined(); + expect(fetchMediaArgs.ssrfPolicy).toEqual({ allowedHostnames: ["localhost"] }); + }); + + it("auto-allowlists private IP serverUrl hostname when allowPrivateNetwork is not set", async () => { + const mockBuffer = new Uint8Array([1]); + mockFetch.mockResolvedValueOnce({ + ok: true, + headers: new Headers(), + arrayBuffer: () => Promise.resolve(mockBuffer.buffer), + }); + + const attachment: BlueBubblesAttachment = { guid: "att-private-ip" }; + await downloadBlueBubblesAttachment(attachment, { + serverUrl: "http://192.168.1.5:1234", + password: "test", + }); + + const fetchMediaArgs = fetchRemoteMediaMock.mock.calls[0][0] as Record; + expect(fetchMediaArgs.ssrfPolicy).toEqual({ allowedHostnames: ["192.168.1.5"] }); }); }); diff --git a/extensions/bluebubbles/src/attachments.ts b/extensions/bluebubbles/src/attachments.ts index 6ccb04384..ca7ce69a8 100644 --- a/extensions/bluebubbles/src/attachments.ts +++ b/extensions/bluebubbles/src/attachments.ts @@ -62,6 +62,15 @@ function resolveAccount(params: BlueBubblesAttachmentOpts) { return resolveBlueBubblesServerAccount(params); } +function safeExtractHostname(url: string): string | undefined { + try { + const hostname = new URL(url).hostname.trim(); + return hostname || undefined; + } catch { + return undefined; + } +} + type MediaFetchErrorCode = "max_bytes" | "http_error" | "fetch_failed"; function readMediaFetchErrorCode(error: unknown): MediaFetchErrorCode | undefined { @@ -89,12 +98,17 @@ export async function downloadBlueBubblesAttachment( password, }); const maxBytes = typeof opts.maxBytes === "number" ? opts.maxBytes : DEFAULT_ATTACHMENT_MAX_BYTES; + const trustedHostname = safeExtractHostname(baseUrl); try { const fetched = await getBlueBubblesRuntime().channel.media.fetchRemoteMedia({ url, filePathHint: attachment.transferName ?? attachment.guid ?? "attachment", maxBytes, - ssrfPolicy: allowPrivateNetwork ? { allowPrivateNetwork: true } : undefined, + ssrfPolicy: allowPrivateNetwork + ? { allowPrivateNetwork: true } + : trustedHostname + ? { allowedHostnames: [trustedHostname] } + : undefined, fetchImpl: async (input, init) => await blueBubblesFetchWithTimeout( resolveRequestUrl(input),