fix(bluebubbles): allow configured host for attachment SSRF guard

Co-authored-by: damaozi <1811866786@qq.com>
This commit is contained in:
Peter Steinberger
2026-02-26 16:40:38 +01:00
parent 4da6a7f212
commit 7d9397099b
3 changed files with 36 additions and 3 deletions

View File

@@ -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<string, unknown>;
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<string, unknown>;
expect(fetchMediaArgs.ssrfPolicy).toEqual({ allowedHostnames: ["192.168.1.5"] });
});
});

View File

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