Skip to content

Commit 055c17b

Browse files
authored
bluebubbles: consolidate HTTP traffic through typed BlueBubblesClient (#68234)
Merged via squash. Prepared head SHA: ee72657 Co-authored-by: omarshahine <[email protected]> Co-authored-by: omarshahine <[email protected]> Reviewed-by: @omarshahine
1 parent 84cd786 commit 055c17b

15 files changed

Lines changed: 1376 additions & 314 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai
1616
- Cron/CLI: parse PowerShell-style `--tools` allow-lists the same way as comma-separated input, so `cron add` and `cron edit` no longer persist `exec read write` as one combined tool entry on Windows. (#68858) Thanks @chen-zhang-cs-code.
1717
- Browser/user-profile: let existing-session `profile="user"` tool calls auto-route to a connected browser node or use explicit `target="node"`, while still honoring explicit `target="host"` pinning. (#48677)
1818
- Discord/slash commands: tolerate partial Discord channel metadata in slash-command and model-picker flows so partial channel objects no longer crash when channel names, topics, or thread parent metadata are unavailable. (#68953) Thanks @dutifulbob.
19+
- BlueBubbles: consolidate outbound HTTP through a typed `BlueBubblesClient` that resolves the SSRF policy once at construction so image attachments stop getting blocked on localhost and reactions stop getting blocked on private-IP BB deployments. Fixes #34749 and #59722. (#68234) Thanks @omarshahine.
1920

2021
## 2026.4.19-beta.2
2122

extensions/bluebubbles/src/attachments.test.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -341,8 +341,12 @@ describe("downloadBlueBubblesAttachment", () => {
341341
},
342342
});
343343

344+
// Default-deny policy via the guard, NOT unguarded fetch. Aisle #68234
345+
// flagged the previous `undefined` fallback as a real SSRF bypass because
346+
// `blueBubblesFetchWithTimeout` treats `undefined` as "skip the SSRF
347+
// guard entirely", exactly when the user asked us to block private nets.
344348
const fetchMediaArgs = fetchRemoteMediaMock.mock.calls[0][0] as Record<string, unknown>;
345-
expect(fetchMediaArgs.ssrfPolicy).toBeUndefined();
349+
expect(fetchMediaArgs.ssrfPolicy).toEqual({});
346350
});
347351

348352
it("allowlists public serverUrl hostname when allowPrivateNetwork is not set", async () => {

extensions/bluebubbles/src/attachments.ts

Lines changed: 32 additions & 117 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,27 @@
11
import crypto from "node:crypto";
22
import path from "node:path";
3-
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
4-
import { isBlockedHostnameOrIp } from "openclaw/plugin-sdk/ssrf-runtime";
53
import {
64
normalizeLowercaseStringOrEmpty,
75
normalizeOptionalLowercaseString,
86
normalizeOptionalString,
97
} from "openclaw/plugin-sdk/text-runtime";
108
import { resolveBlueBubblesServerAccount } from "./account-resolve.js";
11-
import { extractAttachments } from "./monitor-normalize.js";
12-
import { assertMultipartActionOk, postMultipartFormData } from "./multipart.js";
9+
import {
10+
createBlueBubblesClient,
11+
createBlueBubblesClientFromParts,
12+
type BlueBubblesClient,
13+
} from "./client.js";
14+
import { assertMultipartActionOk } from "./multipart.js";
1315
import {
1416
fetchBlueBubblesServerInfo,
1517
getCachedBlueBubblesPrivateApiStatus,
1618
isBlueBubblesPrivateApiStatusEnabled,
1719
} from "./probe.js";
18-
import { resolveRequestUrl } from "./request-url.js";
1920
import type { OpenClawConfig } from "./runtime-api.js";
20-
import { getBlueBubblesRuntime, warnBlueBubbles } from "./runtime.js";
21+
import { warnBlueBubbles } from "./runtime.js";
2122
import { extractBlueBubblesMessageId, resolveBlueBubblesSendTarget } from "./send-helpers.js";
2223
import { createChatForHandle, resolveChatGuidForTarget } from "./send.js";
23-
import {
24-
blueBubblesFetchWithTimeout,
25-
buildBlueBubblesApiUrl,
26-
type BlueBubblesAttachment,
27-
type SsrFPolicy,
28-
} from "./types.js";
29-
30-
function blueBubblesPolicy(allowPrivateNetwork: boolean | undefined): SsrFPolicy | undefined {
31-
// Pass `undefined` (not `{}`) for the non-private case so the non-SSRF fallback path
32-
// is used. An empty `{}` policy routes through the SSRF guard, which blocks the
33-
// localhost BB deployments that are the most common self-hosted setup. The opt-in
34-
// private-network branch keeps the explicit policy. (#64105, #67510)
35-
return allowPrivateNetwork ? { allowPrivateNetwork: true } : undefined;
36-
}
24+
import { type BlueBubblesAttachment } from "./types.js";
3725

3826
export type BlueBubblesAttachmentOpts = {
3927
serverUrl?: string;
@@ -43,7 +31,6 @@ export type BlueBubblesAttachmentOpts = {
4331
cfg?: OpenClawConfig;
4432
};
4533

46-
const DEFAULT_ATTACHMENT_MAX_BYTES = 8 * 1024 * 1024;
4734
const AUDIO_MIME_MP3 = new Set(["audio/mpeg", "audio/mp3"]);
4835
const AUDIO_MIME_CAF = new Set(["audio/x-caf", "audio/caf"]);
4936

@@ -75,29 +62,12 @@ function resolveVoiceInfo(filename: string, contentType?: string) {
7562
return { isAudio, isMp3, isCaf };
7663
}
7764

78-
function resolveAccount(params: BlueBubblesAttachmentOpts) {
79-
return resolveBlueBubblesServerAccount(params);
80-
}
81-
82-
function safeExtractHostname(url: string): string | undefined {
83-
try {
84-
const hostname = new URL(url).hostname.trim();
85-
return hostname || undefined;
86-
} catch {
87-
return undefined;
88-
}
65+
function clientFromOpts(params: BlueBubblesAttachmentOpts): BlueBubblesClient {
66+
return createBlueBubblesClient(params);
8967
}
9068

91-
type MediaFetchErrorCode = "max_bytes" | "http_error" | "fetch_failed";
92-
93-
function readMediaFetchErrorCode(error: unknown): MediaFetchErrorCode | undefined {
94-
if (!error || typeof error !== "object") {
95-
return undefined;
96-
}
97-
const code = (error as { code?: unknown }).code;
98-
return code === "max_bytes" || code === "http_error" || code === "fetch_failed"
99-
? code
100-
: undefined;
69+
function resolveAccount(params: BlueBubblesAttachmentOpts) {
70+
return resolveBlueBubblesServerAccount(params);
10171
}
10272

10373
/**
@@ -117,82 +87,28 @@ export async function fetchBlueBubblesMessageAttachments(
11787
allowPrivateNetwork?: boolean;
11888
},
11989
): Promise<BlueBubblesAttachment[]> {
120-
const url = buildBlueBubblesApiUrl({
90+
const client = createBlueBubblesClientFromParts({
12191
baseUrl: opts.baseUrl,
122-
path: `/api/v1/message/${encodeURIComponent(messageGuid)}`,
12392
password: opts.password,
93+
allowPrivateNetwork: opts.allowPrivateNetwork === true,
94+
timeoutMs: opts.timeoutMs,
12495
});
125-
// Pass undefined (not {}) when private network is not opted-in so the
126-
// non-SSRF fallback path is used — an empty {} triggers the SSRF-guarded
127-
// path which blocks localhost BB servers by default. (#64105)
128-
const policy: SsrFPolicy | undefined = opts.allowPrivateNetwork
129-
? { allowPrivateNetwork: true }
130-
: undefined;
131-
const response = await blueBubblesFetchWithTimeout(
132-
url,
133-
{ method: "GET" },
134-
opts.timeoutMs,
135-
policy,
136-
);
137-
if (!response.ok) {
138-
return [];
139-
}
140-
const json = (await response.json()) as Record<string, unknown>;
141-
const data = json.data as Record<string, unknown> | undefined;
142-
if (!data) {
143-
return [];
144-
}
145-
return extractAttachments(data);
96+
return await client.getMessageAttachments({ messageGuid, timeoutMs: opts.timeoutMs });
14697
}
14798

14899
export async function downloadBlueBubblesAttachment(
149100
attachment: BlueBubblesAttachment,
150101
opts: BlueBubblesAttachmentOpts & { maxBytes?: number } = {},
151102
): Promise<{ buffer: Uint8Array; contentType?: string }> {
152-
const guid = attachment.guid?.trim();
153-
if (!guid) {
154-
throw new Error("BlueBubbles attachment guid is required");
155-
}
156-
const { baseUrl, password, allowPrivateNetwork, allowPrivateNetworkConfig } =
157-
resolveAccount(opts);
158-
const url = buildBlueBubblesApiUrl({
159-
baseUrl,
160-
path: `/api/v1/attachment/${encodeURIComponent(guid)}/download`,
161-
password,
103+
const client = clientFromOpts(opts);
104+
// client.downloadAttachment threads this.ssrfPolicy to BOTH fetchRemoteMedia
105+
// and the fetchImpl callback — closing the gap in #34749 where the legacy
106+
// helper silently omitted the policy on the callback path.
107+
return await client.downloadAttachment({
108+
attachment,
109+
maxBytes: opts.maxBytes,
110+
timeoutMs: opts.timeoutMs,
162111
});
163-
const maxBytes = typeof opts.maxBytes === "number" ? opts.maxBytes : DEFAULT_ATTACHMENT_MAX_BYTES;
164-
const trustedHostname = safeExtractHostname(baseUrl);
165-
const trustedHostnameIsPrivate = trustedHostname ? isBlockedHostnameOrIp(trustedHostname) : false;
166-
try {
167-
const fetched = await getBlueBubblesRuntime().channel.media.fetchRemoteMedia({
168-
url,
169-
filePathHint: attachment.transferName ?? attachment.guid ?? "attachment",
170-
maxBytes,
171-
ssrfPolicy: allowPrivateNetwork
172-
? { allowPrivateNetwork: true }
173-
: trustedHostname && (allowPrivateNetworkConfig !== false || !trustedHostnameIsPrivate)
174-
? { allowedHostnames: [trustedHostname] }
175-
: undefined,
176-
fetchImpl: async (input, init) =>
177-
await blueBubblesFetchWithTimeout(
178-
resolveRequestUrl(input),
179-
{ ...init, method: init?.method ?? "GET" },
180-
opts.timeoutMs,
181-
),
182-
});
183-
return {
184-
buffer: new Uint8Array(fetched.buffer),
185-
contentType: fetched.contentType ?? attachment.mimeType ?? undefined,
186-
};
187-
} catch (error) {
188-
if (readMediaFetchErrorCode(error) === "max_bytes") {
189-
throw new Error(`BlueBubbles attachment too large (limit ${maxBytes} bytes)`, {
190-
cause: error,
191-
});
192-
}
193-
const text = formatErrorMessage(error);
194-
throw new Error(`BlueBubbles attachment download failed: ${text}`, { cause: error });
195-
}
196112
}
197113

198114
export type SendBlueBubblesAttachmentResult = {
@@ -221,7 +137,13 @@ export async function sendBlueBubblesAttachment(params: {
221137
const fallbackName = wantsVoice ? "Audio Message" : "attachment";
222138
filename = sanitizeFilename(filename, fallbackName);
223139
contentType = normalizeOptionalString(contentType);
140+
// Resolve account tuple for helpers that still need baseUrl/password
141+
// (createChatForHandle, resolveChatGuidForTarget, fetchBlueBubblesServerInfo).
142+
// These migrate to the client in subsequent passes. For this callsite, the
143+
// client owns the actual attachment POST; the resolved tuple stays alongside
144+
// so chat-guid resolution and Private API probe continue to work.
224145
const { baseUrl, password, accountId, allowPrivateNetwork } = resolveAccount(opts);
146+
const client = createBlueBubblesClient(opts);
225147
let privateApiStatus = getCachedBlueBubblesPrivateApiStatus(accountId);
226148

227149
// Lazy refresh: when the cache has expired and Private API features are needed,
@@ -302,12 +224,6 @@ export async function sendBlueBubblesAttachment(params: {
302224
}
303225
}
304226

305-
const url = buildBlueBubblesApiUrl({
306-
baseUrl,
307-
path: "/api/v1/message/attachment",
308-
password,
309-
});
310-
311227
// Build FormData with the attachment
312228
const boundary = `----BlueBubblesFormBoundary${crypto.randomUUID().replace(/-/g, "")}`;
313229
const parts: Uint8Array[] = [];
@@ -365,12 +281,11 @@ export async function sendBlueBubblesAttachment(params: {
365281
// Close the multipart body
366282
parts.push(encoder.encode(`--${boundary}--\r\n`));
367283

368-
const res = await postMultipartFormData({
369-
url,
284+
const res = await client.requestMultipart({
285+
path: "/api/v1/message/attachment",
370286
boundary,
371287
parts,
372288
timeoutMs: opts.timeoutMs ?? 60_000, // longer timeout for file uploads
373-
ssrfPolicy: blueBubblesPolicy(allowPrivateNetwork),
374289
});
375290

376291
await assertMultipartActionOk(res, "attachment send");

extensions/bluebubbles/src/catchup.ts

Lines changed: 17 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,11 @@ import { readJsonFileWithFallback, writeJsonFileAtomically } from "openclaw/plug
44
import { resolveStateDir } from "openclaw/plugin-sdk/state-paths";
55
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
66
import { resolveBlueBubblesServerAccount } from "./account-resolve.js";
7+
import { createBlueBubblesClientFromParts } from "./client.js";
78
import { warmupBlueBubblesInboundDedupe } from "./inbound-dedupe.js";
89
import { asRecord, normalizeWebhookMessage } from "./monitor-normalize.js";
910
import { processMessage } from "./monitor-processing.js";
1011
import type { WebhookTarget } from "./monitor-shared.js";
11-
import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js";
1212

1313
// When the gateway is down, restarting, or wedged, inbound webhook POSTs from
1414
// BB Server fail with ECONNRESET/ECONNREFUSED. BB's WebhookService does not
@@ -236,32 +236,27 @@ export async function fetchBlueBubblesMessagesSince(
236236
limit: number,
237237
opts: FetchOpts,
238238
): Promise<BlueBubblesCatchupFetchResult> {
239-
const ssrfPolicy = opts.allowPrivateNetwork ? { allowPrivateNetwork: true } : {};
240-
const url = buildBlueBubblesApiUrl({
239+
const client = createBlueBubblesClientFromParts({
241240
baseUrl: opts.baseUrl,
242-
path: "/api/v1/message/query",
243241
password: opts.password,
244-
});
245-
const body = JSON.stringify({
246-
limit,
247-
sort: "ASC",
248-
after: sinceMs,
249-
// `with` mirrors what bb-catchup.sh uses and what the normal webhook
250-
// payload carries, so normalizeWebhookMessage has the same fields to
251-
// read during replay as it does on live dispatch.
252-
with: ["chat", "chat.participants", "attachment"],
242+
allowPrivateNetwork: opts.allowPrivateNetwork,
243+
timeoutMs: opts.timeoutMs ?? FETCH_TIMEOUT_MS,
253244
});
254245
try {
255-
const res = await blueBubblesFetchWithTimeout(
256-
url,
257-
{
258-
method: "POST",
259-
headers: { "Content-Type": "application/json" },
260-
body,
246+
const res = await client.request({
247+
method: "POST",
248+
path: "/api/v1/message/query",
249+
body: {
250+
limit,
251+
sort: "ASC",
252+
after: sinceMs,
253+
// `with` mirrors what bb-catchup.sh uses and what the normal webhook
254+
// payload carries, so normalizeWebhookMessage has the same fields to
255+
// read during replay as it does on live dispatch.
256+
with: ["chat", "chat.participants", "attachment"],
261257
},
262-
opts.timeoutMs ?? FETCH_TIMEOUT_MS,
263-
ssrfPolicy,
264-
);
258+
timeoutMs: opts.timeoutMs ?? FETCH_TIMEOUT_MS,
259+
});
265260
if (!res.ok) {
266261
return { resolved: false, messages: [] };
267262
}

0 commit comments

Comments
 (0)