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

@@ -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.

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