fix(bluebubbles): pass SSRF policy for localhost attachment downloads (#24457)

(cherry picked from commit aff64567c7)
This commit is contained in:
Marcus Castro
2026-02-23 11:07:49 -03:00
committed by Peter Steinberger
parent 113545f005
commit dd41a78458
5 changed files with 55 additions and 2 deletions

View File

@@ -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,
};
}

View File

@@ -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<string, unknown>;
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<string, unknown>;
expect(fetchMediaArgs.ssrfPolicy).toBeUndefined();
});
});
describe("sendBlueBubblesAttachment", () => {

View File

@@ -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),

View File

@@ -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(),
})

View File

@@ -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<string, BlueBubblesGroupConfig>;
};