diff --git a/extensions/bluebubbles/src/account-resolve.ts b/extensions/bluebubbles/src/account-resolve.ts index 0ec539644fe..904d21d4d3f 100644 --- a/extensions/bluebubbles/src/account-resolve.ts +++ b/extensions/bluebubbles/src/account-resolve.ts @@ -12,6 +12,7 @@ export function resolveBlueBubblesServerAccount(params: BlueBubblesAccountResolv baseUrl: string; password: string; accountId: string; + allowPrivateNetwork: boolean; } { const account = resolveBlueBubblesAccount({ cfg: params.cfg ?? {}, @@ -25,5 +26,10 @@ export function resolveBlueBubblesServerAccount(params: BlueBubblesAccountResolv if (!password) { throw new Error("BlueBubbles password is required"); } - return { baseUrl, password, accountId: account.accountId }; + return { + baseUrl, + password, + accountId: account.accountId, + allowPrivateNetwork: account.config.allowPrivateNetwork === true, + }; } diff --git a/extensions/bluebubbles/src/attachments.test.ts b/extensions/bluebubbles/src/attachments.test.ts index 7ebab0485df..d6b12d311f8 100644 --- a/extensions/bluebubbles/src/attachments.test.ts +++ b/extensions/bluebubbles/src/attachments.test.ts @@ -268,6 +268,49 @@ describe("downloadBlueBubblesAttachment", () => { expect(calledUrl).toContain("password=config-password"); expect(result.buffer).toEqual(new Uint8Array([1])); }); + + it("passes ssrfPolicy with allowPrivateNetwork when config enables it", async () => { + const mockBuffer = new Uint8Array([1]); + mockFetch.mockResolvedValueOnce({ + ok: true, + headers: new Headers(), + arrayBuffer: () => Promise.resolve(mockBuffer.buffer), + }); + + const attachment: BlueBubblesAttachment = { guid: "att-ssrf" }; + await downloadBlueBubblesAttachment(attachment, { + cfg: { + channels: { + bluebubbles: { + serverUrl: "http://localhost:1234", + password: "test", + allowPrivateNetwork: true, + }, + }, + }, + }); + + const fetchMediaArgs = fetchRemoteMediaMock.mock.calls[0][0] as Record; + expect(fetchMediaArgs.ssrfPolicy).toEqual({ allowPrivateNetwork: true }); + }); + + it("does not pass ssrfPolicy 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-no-ssrf" }; + await downloadBlueBubblesAttachment(attachment, { + serverUrl: "http://localhost:1234", + password: "test", + }); + + const fetchMediaArgs = fetchRemoteMediaMock.mock.calls[0][0] as Record; + expect(fetchMediaArgs.ssrfPolicy).toBeUndefined(); + }); }); describe("sendBlueBubblesAttachment", () => { diff --git a/extensions/bluebubbles/src/attachments.ts b/extensions/bluebubbles/src/attachments.ts index 3b8850f2154..6ccb043845f 100644 --- a/extensions/bluebubbles/src/attachments.ts +++ b/extensions/bluebubbles/src/attachments.ts @@ -82,7 +82,7 @@ export async function downloadBlueBubblesAttachment( if (!guid) { throw new Error("BlueBubbles attachment guid is required"); } - const { baseUrl, password } = resolveAccount(opts); + const { baseUrl, password, allowPrivateNetwork } = resolveAccount(opts); const url = buildBlueBubblesApiUrl({ baseUrl, path: `/api/v1/attachment/${encodeURIComponent(guid)}/download`, @@ -94,6 +94,7 @@ export async function downloadBlueBubblesAttachment( url, filePathHint: attachment.transferName ?? attachment.guid ?? "attachment", maxBytes, + ssrfPolicy: allowPrivateNetwork ? { allowPrivateNetwork: true } : undefined, fetchImpl: async (input, init) => await blueBubblesFetchWithTimeout( resolveRequestUrl(input), diff --git a/extensions/bluebubbles/src/config-schema.ts b/extensions/bluebubbles/src/config-schema.ts index b575ab85fe1..e4bef3fd73b 100644 --- a/extensions/bluebubbles/src/config-schema.ts +++ b/extensions/bluebubbles/src/config-schema.ts @@ -43,6 +43,7 @@ const bluebubblesAccountSchema = z mediaMaxMb: z.number().int().positive().optional(), mediaLocalRoots: z.array(z.string()).optional(), sendReadReceipts: z.boolean().optional(), + allowPrivateNetwork: z.boolean().optional(), blockStreaming: z.boolean().optional(), groups: z.object({}).catchall(bluebubblesGroupConfigSchema).optional(), }) diff --git a/extensions/bluebubbles/src/types.ts b/extensions/bluebubbles/src/types.ts index 7346c4ff42a..72ccd991857 100644 --- a/extensions/bluebubbles/src/types.ts +++ b/extensions/bluebubbles/src/types.ts @@ -53,6 +53,8 @@ export type BlueBubblesAccountConfig = { mediaLocalRoots?: string[]; /** Send read receipts for incoming messages (default: true). */ sendReadReceipts?: boolean; + /** Allow fetching from private/internal IP addresses (e.g. localhost). Required for same-host BlueBubbles setups. */ + allowPrivateNetwork?: boolean; /** Per-group configuration keyed by chat GUID or identifier. */ groups?: Record; };