diff --git a/extensions/bluebubbles/src/attachments.ts b/extensions/bluebubbles/src/attachments.ts index f5c3e4acddd..c10822a2c0e 100644 --- a/extensions/bluebubbles/src/attachments.ts +++ b/extensions/bluebubbles/src/attachments.ts @@ -1,39 +1,27 @@ import crypto from "node:crypto"; import path from "node:path"; -import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; -import { isBlockedHostnameOrIp } from "openclaw/plugin-sdk/ssrf-runtime"; import { normalizeLowercaseStringOrEmpty, normalizeOptionalLowercaseString, normalizeOptionalString, } from "openclaw/plugin-sdk/text-runtime"; import { resolveBlueBubblesServerAccount } from "./account-resolve.js"; -import { extractAttachments } from "./monitor-normalize.js"; -import { assertMultipartActionOk, postMultipartFormData } from "./multipart.js"; +import { + createBlueBubblesClient, + createBlueBubblesClientFromParts, + type BlueBubblesClient, +} from "./client.js"; +import { assertMultipartActionOk } from "./multipart.js"; import { fetchBlueBubblesServerInfo, getCachedBlueBubblesPrivateApiStatus, isBlueBubblesPrivateApiStatusEnabled, } from "./probe.js"; -import { resolveRequestUrl } from "./request-url.js"; import type { OpenClawConfig } from "./runtime-api.js"; -import { getBlueBubblesRuntime, warnBlueBubbles } from "./runtime.js"; +import { warnBlueBubbles } from "./runtime.js"; import { extractBlueBubblesMessageId, resolveBlueBubblesSendTarget } from "./send-helpers.js"; import { createChatForHandle, resolveChatGuidForTarget } from "./send.js"; -import { - blueBubblesFetchWithTimeout, - buildBlueBubblesApiUrl, - type BlueBubblesAttachment, - type SsrFPolicy, -} from "./types.js"; - -function blueBubblesPolicy(allowPrivateNetwork: boolean | undefined): SsrFPolicy | undefined { - // Pass `undefined` (not `{}`) for the non-private case so the non-SSRF fallback path - // is used. An empty `{}` policy routes through the SSRF guard, which blocks the - // localhost BB deployments that are the most common self-hosted setup. The opt-in - // private-network branch keeps the explicit policy. (#64105, #67510) - return allowPrivateNetwork ? { allowPrivateNetwork: true } : undefined; -} +import { type BlueBubblesAttachment } from "./types.js"; export type BlueBubblesAttachmentOpts = { serverUrl?: string; @@ -43,7 +31,6 @@ export type BlueBubblesAttachmentOpts = { cfg?: OpenClawConfig; }; -const DEFAULT_ATTACHMENT_MAX_BYTES = 8 * 1024 * 1024; const AUDIO_MIME_MP3 = new Set(["audio/mpeg", "audio/mp3"]); const AUDIO_MIME_CAF = new Set(["audio/x-caf", "audio/caf"]); @@ -75,31 +62,14 @@ function resolveVoiceInfo(filename: string, contentType?: string) { return { isAudio, isMp3, isCaf }; } +function clientFromOpts(params: BlueBubblesAttachmentOpts): BlueBubblesClient { + return createBlueBubblesClient(params); +} + 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 { - if (!error || typeof error !== "object") { - return undefined; - } - const code = (error as { code?: unknown }).code; - return code === "max_bytes" || code === "http_error" || code === "fetch_failed" - ? code - : undefined; -} - /** * Fetch attachment metadata for a message from the BlueBubbles API. * @@ -117,82 +87,28 @@ export async function fetchBlueBubblesMessageAttachments( allowPrivateNetwork?: boolean; }, ): Promise { - const url = buildBlueBubblesApiUrl({ + const client = createBlueBubblesClientFromParts({ baseUrl: opts.baseUrl, - path: `/api/v1/message/${encodeURIComponent(messageGuid)}`, password: opts.password, + allowPrivateNetwork: opts.allowPrivateNetwork === true, + timeoutMs: opts.timeoutMs, }); - // Pass undefined (not {}) when private network is not opted-in so the - // non-SSRF fallback path is used — an empty {} triggers the SSRF-guarded - // path which blocks localhost BB servers by default. (#64105) - const policy: SsrFPolicy | undefined = opts.allowPrivateNetwork - ? { allowPrivateNetwork: true } - : undefined; - const response = await blueBubblesFetchWithTimeout( - url, - { method: "GET" }, - opts.timeoutMs, - policy, - ); - if (!response.ok) { - return []; - } - const json = (await response.json()) as Record; - const data = json.data as Record | undefined; - if (!data) { - return []; - } - return extractAttachments(data); + return await client.getMessageAttachments({ messageGuid, timeoutMs: opts.timeoutMs }); } export async function downloadBlueBubblesAttachment( attachment: BlueBubblesAttachment, opts: BlueBubblesAttachmentOpts & { maxBytes?: number } = {}, ): Promise<{ buffer: Uint8Array; contentType?: string }> { - const guid = attachment.guid?.trim(); - if (!guid) { - throw new Error("BlueBubbles attachment guid is required"); - } - const { baseUrl, password, allowPrivateNetwork, allowPrivateNetworkConfig } = - resolveAccount(opts); - const url = buildBlueBubblesApiUrl({ - baseUrl, - path: `/api/v1/attachment/${encodeURIComponent(guid)}/download`, - password, + const client = clientFromOpts(opts); + // client.downloadAttachment threads this.ssrfPolicy to BOTH fetchRemoteMedia + // and the fetchImpl callback — closing the gap in #34749 where the legacy + // helper silently omitted the policy on the callback path. + return await client.downloadAttachment({ + attachment, + maxBytes: opts.maxBytes, + timeoutMs: opts.timeoutMs, }); - const maxBytes = typeof opts.maxBytes === "number" ? opts.maxBytes : DEFAULT_ATTACHMENT_MAX_BYTES; - const trustedHostname = safeExtractHostname(baseUrl); - const trustedHostnameIsPrivate = trustedHostname ? isBlockedHostnameOrIp(trustedHostname) : false; - try { - const fetched = await getBlueBubblesRuntime().channel.media.fetchRemoteMedia({ - url, - filePathHint: attachment.transferName ?? attachment.guid ?? "attachment", - maxBytes, - ssrfPolicy: allowPrivateNetwork - ? { allowPrivateNetwork: true } - : trustedHostname && (allowPrivateNetworkConfig !== false || !trustedHostnameIsPrivate) - ? { allowedHostnames: [trustedHostname] } - : undefined, - fetchImpl: async (input, init) => - await blueBubblesFetchWithTimeout( - resolveRequestUrl(input), - { ...init, method: init?.method ?? "GET" }, - opts.timeoutMs, - ), - }); - return { - buffer: new Uint8Array(fetched.buffer), - contentType: fetched.contentType ?? attachment.mimeType ?? undefined, - }; - } catch (error) { - if (readMediaFetchErrorCode(error) === "max_bytes") { - throw new Error(`BlueBubbles attachment too large (limit ${maxBytes} bytes)`, { - cause: error, - }); - } - const text = formatErrorMessage(error); - throw new Error(`BlueBubbles attachment download failed: ${text}`, { cause: error }); - } } export type SendBlueBubblesAttachmentResult = { @@ -221,7 +137,13 @@ export async function sendBlueBubblesAttachment(params: { const fallbackName = wantsVoice ? "Audio Message" : "attachment"; filename = sanitizeFilename(filename, fallbackName); contentType = normalizeOptionalString(contentType); + // Resolve account tuple for helpers that still need baseUrl/password + // (createChatForHandle, resolveChatGuidForTarget, fetchBlueBubblesServerInfo). + // These migrate to the client in subsequent passes. For this callsite, the + // client owns the actual attachment POST; the resolved tuple stays alongside + // so chat-guid resolution and Private API probe continue to work. const { baseUrl, password, accountId, allowPrivateNetwork } = resolveAccount(opts); + const client = createBlueBubblesClient(opts); let privateApiStatus = getCachedBlueBubblesPrivateApiStatus(accountId); // Lazy refresh: when the cache has expired and Private API features are needed, @@ -302,12 +224,6 @@ export async function sendBlueBubblesAttachment(params: { } } - const url = buildBlueBubblesApiUrl({ - baseUrl, - path: "/api/v1/message/attachment", - password, - }); - // Build FormData with the attachment const boundary = `----BlueBubblesFormBoundary${crypto.randomUUID().replace(/-/g, "")}`; const parts: Uint8Array[] = []; @@ -365,12 +281,11 @@ export async function sendBlueBubblesAttachment(params: { // Close the multipart body parts.push(encoder.encode(`--${boundary}--\r\n`)); - const res = await postMultipartFormData({ - url, + const res = await client.requestMultipart({ + path: "/api/v1/message/attachment", boundary, parts, timeoutMs: opts.timeoutMs ?? 60_000, // longer timeout for file uploads - ssrfPolicy: blueBubblesPolicy(allowPrivateNetwork), }); await assertMultipartActionOk(res, "attachment send"); diff --git a/extensions/bluebubbles/src/catchup.ts b/extensions/bluebubbles/src/catchup.ts index 0aa70342264..ec49e45a691 100644 --- a/extensions/bluebubbles/src/catchup.ts +++ b/extensions/bluebubbles/src/catchup.ts @@ -4,11 +4,11 @@ import { readJsonFileWithFallback, writeJsonFileAtomically } from "openclaw/plug import { resolveStateDir } from "openclaw/plugin-sdk/state-paths"; import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path"; import { resolveBlueBubblesServerAccount } from "./account-resolve.js"; +import { createBlueBubblesClientFromParts } from "./client.js"; import { warmupBlueBubblesInboundDedupe } from "./inbound-dedupe.js"; import { asRecord, normalizeWebhookMessage } from "./monitor-normalize.js"; import { processMessage } from "./monitor-processing.js"; import type { WebhookTarget } from "./monitor-shared.js"; -import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js"; // When the gateway is down, restarting, or wedged, inbound webhook POSTs from // BB Server fail with ECONNRESET/ECONNREFUSED. BB's WebhookService does not @@ -236,32 +236,27 @@ export async function fetchBlueBubblesMessagesSince( limit: number, opts: FetchOpts, ): Promise { - const ssrfPolicy = opts.allowPrivateNetwork ? { allowPrivateNetwork: true } : {}; - const url = buildBlueBubblesApiUrl({ + const client = createBlueBubblesClientFromParts({ baseUrl: opts.baseUrl, - path: "/api/v1/message/query", password: opts.password, - }); - const body = JSON.stringify({ - limit, - sort: "ASC", - after: sinceMs, - // `with` mirrors what bb-catchup.sh uses and what the normal webhook - // payload carries, so normalizeWebhookMessage has the same fields to - // read during replay as it does on live dispatch. - with: ["chat", "chat.participants", "attachment"], + allowPrivateNetwork: opts.allowPrivateNetwork, + timeoutMs: opts.timeoutMs ?? FETCH_TIMEOUT_MS, }); try { - const res = await blueBubblesFetchWithTimeout( - url, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body, + const res = await client.request({ + method: "POST", + path: "/api/v1/message/query", + body: { + limit, + sort: "ASC", + after: sinceMs, + // `with` mirrors what bb-catchup.sh uses and what the normal webhook + // payload carries, so normalizeWebhookMessage has the same fields to + // read during replay as it does on live dispatch. + with: ["chat", "chat.participants", "attachment"], }, - opts.timeoutMs ?? FETCH_TIMEOUT_MS, - ssrfPolicy, - ); + timeoutMs: opts.timeoutMs ?? FETCH_TIMEOUT_MS, + }); if (!res.ok) { return { resolved: false, messages: [] }; } diff --git a/extensions/bluebubbles/src/chat.ts b/extensions/bluebubbles/src/chat.ts index 0e7e61527df..edf1bd5235c 100644 --- a/extensions/bluebubbles/src/chat.ts +++ b/extensions/bluebubbles/src/chat.ts @@ -1,15 +1,9 @@ import crypto from "node:crypto"; import path from "node:path"; -import type { SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime"; -import { resolveBlueBubblesServerAccount } from "./account-resolve.js"; -import { assertMultipartActionOk, postMultipartFormData } from "./multipart.js"; +import { createBlueBubblesClient, type BlueBubblesClient } from "./client.js"; +import { assertMultipartActionOk } from "./multipart.js"; import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js"; import type { OpenClawConfig } from "./runtime-api.js"; -import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js"; - -function blueBubblesPolicy(allowPrivateNetwork: boolean): SsrFPolicy { - return allowPrivateNetwork ? { allowPrivateNetwork: true } : {}; -} export type BlueBubblesChatOpts = { serverUrl?: string; @@ -19,8 +13,8 @@ export type BlueBubblesChatOpts = { cfg?: OpenClawConfig; }; -function resolveAccount(params: BlueBubblesChatOpts) { - return resolveBlueBubblesServerAccount(params); +function clientFromOpts(params: BlueBubblesChatOpts): BlueBubblesClient { + return createBlueBubblesClient(params); } function assertPrivateApiEnabled(accountId: string, feature: string): void { @@ -46,21 +40,15 @@ async function sendBlueBubblesChatEndpointRequest(params: { if (!trimmed) { return; } - const { baseUrl, password, accountId, allowPrivateNetwork } = resolveAccount(params.opts); - if (getCachedBlueBubblesPrivateApiStatus(accountId) === false) { + const client = clientFromOpts(params.opts); + if (getCachedBlueBubblesPrivateApiStatus(client.accountId) === false) { return; } - const url = buildBlueBubblesApiUrl({ - baseUrl, + const res = await client.request({ + method: params.method, path: `/api/v1/chat/${encodeURIComponent(trimmed)}/${params.endpoint}`, - password, + timeoutMs: params.opts.timeoutMs, }); - const res = await blueBubblesFetchWithTimeout( - url, - { method: params.method }, - params.opts.timeoutMs, - blueBubblesPolicy(allowPrivateNetwork), - ); await assertMultipartActionOk(res, params.action); } @@ -72,26 +60,14 @@ async function sendPrivateApiJsonRequest(params: { method: "POST" | "PUT" | "DELETE"; payload?: unknown; }): Promise { - const { baseUrl, password, accountId, allowPrivateNetwork } = resolveAccount(params.opts); - assertPrivateApiEnabled(accountId, params.feature); - const url = buildBlueBubblesApiUrl({ - baseUrl, + const client = clientFromOpts(params.opts); + assertPrivateApiEnabled(client.accountId, params.feature); + const res = await client.request({ + method: params.method, path: params.path, - password, + body: params.payload, + timeoutMs: params.opts.timeoutMs, }); - - const request: RequestInit = { method: params.method }; - if (params.payload !== undefined) { - request.headers = { "Content-Type": "application/json" }; - request.body = JSON.stringify(params.payload); - } - - const res = await blueBubblesFetchWithTimeout( - url, - request, - params.opts.timeoutMs, - blueBubblesPolicy(allowPrivateNetwork), - ); await assertMultipartActionOk(res, params.action); } @@ -293,13 +269,8 @@ export async function setGroupIconBlueBubbles( throw new Error("BlueBubbles setGroupIcon requires image buffer"); } - const { baseUrl, password, accountId, allowPrivateNetwork } = resolveAccount(opts); - assertPrivateApiEnabled(accountId, "setGroupIcon"); - const url = buildBlueBubblesApiUrl({ - baseUrl, - path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}/icon`, - password, - }); + const client = clientFromOpts(opts); + assertPrivateApiEnabled(client.accountId, "setGroupIcon"); // Build multipart form-data const boundary = `----BlueBubblesFormBoundary${crypto.randomUUID().replace(/-/g, "")}`; @@ -323,12 +294,11 @@ export async function setGroupIconBlueBubbles( // Close multipart body parts.push(encoder.encode(`--${boundary}--\r\n`)); - const res = await postMultipartFormData({ - url, + const res = await client.requestMultipart({ + path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}/icon`, boundary, parts, timeoutMs: opts.timeoutMs ?? 60_000, // longer timeout for file uploads - ssrfPolicy: blueBubblesPolicy(allowPrivateNetwork), }); await assertMultipartActionOk(res, "setGroupIcon"); diff --git a/extensions/bluebubbles/src/client.test.ts b/extensions/bluebubbles/src/client.test.ts new file mode 100644 index 00000000000..b1c856ebf7e --- /dev/null +++ b/extensions/bluebubbles/src/client.test.ts @@ -0,0 +1,526 @@ +import type { SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import "./test-mocks.js"; +import { + blueBubblesHeaderAuth, + blueBubblesQueryStringAuth, + BlueBubblesClient, + clearBlueBubblesClientCache, + createBlueBubblesClient, + invalidateBlueBubblesClient, + resolveBlueBubblesClientSsrfPolicy, +} from "./client.js"; +import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js"; +import type { PluginRuntime } from "./runtime-api.js"; +import { setBlueBubblesRuntime } from "./runtime.js"; +import { + createBlueBubblesFetchGuardPassthroughInstaller, + installBlueBubblesFetchTestHooks, +} from "./test-harness.js"; +import type { BlueBubblesAttachment } from "./types.js"; +import { _setFetchGuardForTesting } from "./types.js"; + +// --- Test infrastructure --------------------------------------------------- + +const mockFetch = vi.fn(); + +const fetchRemoteMediaMock = vi.fn( + async (params: { + url: string; + maxBytes?: number; + ssrfPolicy?: SsrFPolicy; + fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise; + }) => { + const fetchFn = params.fetchImpl ?? fetch; + const res = await fetchFn(params.url); + if (!res.ok) { + throw new Error(`media fetch failed: HTTP ${res.status}`); + } + const buffer = Buffer.from(await res.arrayBuffer()); + if (typeof params.maxBytes === "number" && buffer.byteLength > params.maxBytes) { + const error = new Error(`payload exceeds maxBytes ${params.maxBytes}`) as Error & { + code?: string; + }; + error.code = "max_bytes"; + throw error; + } + return { + buffer, + contentType: res.headers.get("content-type") ?? undefined, + fileName: undefined, + }; + }, +); + +installBlueBubblesFetchTestHooks({ + mockFetch, + privateApiStatusMock: vi.mocked(getCachedBlueBubblesPrivateApiStatus), +}); + +const runtimeStub = { + channel: { + media: { + fetchRemoteMedia: + fetchRemoteMediaMock as unknown as PluginRuntime["channel"]["media"]["fetchRemoteMedia"], + }, + }, +} as unknown as PluginRuntime; + +beforeEach(() => { + fetchRemoteMediaMock.mockClear(); + clearBlueBubblesClientCache(); + setBlueBubblesRuntime(runtimeStub); +}); + +afterEach(() => { + clearBlueBubblesClientCache(); +}); + +// --- resolveBlueBubblesClientSsrfPolicy ------------------------------------ + +describe("resolveBlueBubblesClientSsrfPolicy (3-mode policy)", () => { + it("mode 1: user opts in → { allowPrivateNetwork: true } for any hostname", () => { + const result = resolveBlueBubblesClientSsrfPolicy({ + baseUrl: "http://localhost:1234", + allowPrivateNetwork: true, + }); + expect(result.ssrfPolicy).toEqual({ allowPrivateNetwork: true }); + expect(result.trustedHostname).toBe("localhost"); + expect(result.trustedHostnameIsPrivate).toBe(true); + }); + + it("mode 2: private hostname + no opt-out → narrow allowlist { allowedHostnames: [host] }", () => { + const result = resolveBlueBubblesClientSsrfPolicy({ + baseUrl: "http://192.168.1.50:1234", + allowPrivateNetwork: false, + }); + expect(result.ssrfPolicy).toEqual({ allowedHostnames: ["192.168.1.50"] }); + expect(result.trustedHostnameIsPrivate).toBe(true); + }); + + it("mode 2: localhost + no opt-out → narrow allowlist keeps BB reachable without full opt-in", () => { + const result = resolveBlueBubblesClientSsrfPolicy({ + baseUrl: "http://localhost:1234", + allowPrivateNetwork: false, + }); + expect(result.ssrfPolicy).toEqual({ allowedHostnames: ["localhost"] }); + }); + + it("mode 2: public hostname + no opt-in → narrow allowlist for the public host", () => { + const result = resolveBlueBubblesClientSsrfPolicy({ + baseUrl: "https://bb.example.com", + allowPrivateNetwork: false, + }); + expect(result.ssrfPolicy).toEqual({ allowedHostnames: ["bb.example.com"] }); + expect(result.trustedHostnameIsPrivate).toBe(false); + }); + + it("mode 3: private hostname + explicit opt-out → undefined (falls back to non-SSRF path)", () => { + const result = resolveBlueBubblesClientSsrfPolicy({ + baseUrl: "http://192.168.1.50:1234", + allowPrivateNetwork: false, + allowPrivateNetworkConfig: false, + }); + expect(result.ssrfPolicy).toBeUndefined(); + expect(result.trustedHostnameIsPrivate).toBe(true); + }); + + it("mode 3: unparseable baseUrl → undefined policy", () => { + const result = resolveBlueBubblesClientSsrfPolicy({ + baseUrl: "not a url", + allowPrivateNetwork: false, + }); + expect(result.ssrfPolicy).toBeUndefined(); + expect(result.trustedHostname).toBeUndefined(); + }); +}); + +// --- Auth strategies ------------------------------------------------------- + +describe("auth strategies", () => { + it("blueBubblesQueryStringAuth sets ?password= on URL", () => { + const strategy = blueBubblesQueryStringAuth("s3cret"); + const url = new URL("http://localhost:1234/api/v1/ping"); + const init: RequestInit = {}; + strategy.decorate({ url, init }); + expect(url.searchParams.get("password")).toBe("s3cret"); + expect(init.headers).toBeUndefined(); + }); + + it("blueBubblesHeaderAuth sets the auth header and leaves URL clean", () => { + const strategy = blueBubblesHeaderAuth("s3cret"); + const url = new URL("http://localhost:1234/api/v1/ping"); + const init: RequestInit = {}; + strategy.decorate({ url, init }); + expect(url.searchParams.has("password")).toBe(false); + expect(new Headers(init.headers).get("X-BB-Password")).toBe("s3cret"); + }); + + it("blueBubblesHeaderAuth accepts a custom header name", () => { + const strategy = blueBubblesHeaderAuth("s3cret", "Authorization"); + const url = new URL("http://localhost:1234/api/v1/ping"); + const init: RequestInit = {}; + strategy.decorate({ url, init }); + expect(new Headers(init.headers).get("Authorization")).toBe("s3cret"); + }); + + it("auth runs on every request made through the client", async () => { + const client = createBlueBubblesClient({ + serverUrl: "http://localhost:1234", + password: "s3cret", + }); + mockFetch.mockImplementation(() => Promise.resolve(new Response("", { status: 200 }))); + await client.ping(); + await client.getServerInfo(); + const calls = mockFetch.mock.calls; + expect(calls).toHaveLength(2); + expect(String(calls[0]?.[0])).toContain("password=s3cret"); + expect(String(calls[1]?.[0])).toContain("password=s3cret"); + }); + + it("swapping to header auth at factory level keeps URL clean", async () => { + const client = createBlueBubblesClient({ + serverUrl: "http://localhost:1234", + password: "s3cret", + authStrategy: blueBubblesHeaderAuth, + }); + mockFetch.mockResolvedValue(new Response("", { status: 200 })); + await client.ping(); + const [calledUrl, calledInit] = mockFetch.mock.calls[0] ?? []; + expect(String(calledUrl)).not.toContain("password="); + const headers = new Headers((calledInit as RequestInit | undefined)?.headers); + expect(headers.get("X-BB-Password")).toBe("s3cret"); + }); +}); + +// --- Core request path ----------------------------------------------------- + +describe("client.request — SSRF policy threading", () => { + it("threads the same resolved policy to the SSRF guard on every call", async () => { + const capturedPolicies: unknown[] = []; + const installPassthrough = createBlueBubblesFetchGuardPassthroughInstaller(); + installPassthrough((policy) => { + capturedPolicies.push(policy); + }); + mockFetch.mockImplementation(() => Promise.resolve(new Response("{}", { status: 200 }))); + + // Public hostname with no explicit opt-in → mode 2 (narrow allowlist). + const client = createBlueBubblesClient({ + cfg: { + channels: { + bluebubbles: { + serverUrl: "https://bb.example.com", + password: "s3cret", + }, + }, + } as never, + }); + + await client.ping(); + await client.getServerInfo(); + + // Both calls used the same narrow allowlist policy (mode 2). + expect(capturedPolicies).toHaveLength(2); + expect(capturedPolicies[0]).toEqual({ allowedHostnames: ["bb.example.com"] }); + expect(capturedPolicies[1]).toEqual({ allowedHostnames: ["bb.example.com"] }); + }); + + it("private hostname auto-allows (mode 1) without explicit opt-in — preserves existing behavior", async () => { + const capturedPolicies: unknown[] = []; + const installPassthrough = createBlueBubblesFetchGuardPassthroughInstaller(); + installPassthrough((policy) => { + capturedPolicies.push(policy); + }); + mockFetch.mockImplementation(() => Promise.resolve(new Response("{}", { status: 200 }))); + + // 192.168/16 hostname with no config → resolveBlueBubblesEffectiveAllowPrivateNetwork + // auto-allows (accounts-normalization.ts:98-107) → mode 1. + const client = createBlueBubblesClient({ + serverUrl: "http://192.168.1.50:1234", + password: "s3cret", + }); + + await client.ping(); + await client.getServerInfo(); + + expect(capturedPolicies).toHaveLength(2); + expect(capturedPolicies[0]).toEqual({ allowPrivateNetwork: true }); + expect(capturedPolicies[1]).toEqual({ allowPrivateNetwork: true }); + }); + + it("applies full-open policy when user opts into private networks", async () => { + const capturedPolicies: unknown[] = []; + const installPassthrough = createBlueBubblesFetchGuardPassthroughInstaller(); + installPassthrough((policy) => { + capturedPolicies.push(policy); + }); + mockFetch.mockResolvedValue(new Response("{}", { status: 200 })); + + const client = createBlueBubblesClient({ + cfg: { + channels: { + bluebubbles: { + serverUrl: "http://localhost:1234", + password: "s3cret", + network: { dangerouslyAllowPrivateNetwork: true }, + }, + }, + } as never, + }); + + await client.ping(); + expect(capturedPolicies[0]).toEqual({ allowPrivateNetwork: true }); + }); +}); + +// --- #59722 regression: reactions use same policy as other calls ----------- + +describe("client.react (regression for #59722)", () => { + it("uses the same SSRF policy as every other client request (no asymmetric {} fallback)", async () => { + const capturedPolicies: unknown[] = []; + const installPassthrough = createBlueBubblesFetchGuardPassthroughInstaller(); + installPassthrough((policy) => { + capturedPolicies.push(policy); + }); + mockFetch.mockImplementation(() => Promise.resolve(new Response("{}", { status: 200 }))); + + const client = createBlueBubblesClient({ + serverUrl: "http://localhost:1234", + password: "s3cret", + }); + + // Both should carry the same mode-2 allowlist — before this client existed, + // reactions.ts passed `{}` (empty guard) while attachments.ts passed + // `{ allowedHostnames: [...] }`. The asymmetry is what #59722 reported. + await client.ping(); + await client.react({ + chatGuid: "iMessage;+;+15551234567", + selectedMessageGuid: "msg-1", + reaction: "like", + }); + + expect(capturedPolicies).toHaveLength(2); + // The critical assertion: both calls resolved the SAME policy, no + // `{}` vs `{ allowedHostnames }` asymmetry like before consolidation. + expect(capturedPolicies[0]).toEqual(capturedPolicies[1]); + // Localhost auto-allows (private hostname, no explicit opt-out). + expect(capturedPolicies[1]).toEqual({ allowPrivateNetwork: true }); + }); + + it("sends the reaction payload with the correct shape and method", async () => { + mockFetch.mockResolvedValue(new Response("{}", { status: 200 })); + const client = createBlueBubblesClient({ + serverUrl: "http://localhost:1234", + password: "s3cret", + }); + await client.react({ + chatGuid: "chat-guid", + selectedMessageGuid: "msg-1", + reaction: "love", + partIndex: 2, + }); + + const [calledUrl, calledInit] = mockFetch.mock.calls[0] ?? []; + expect(String(calledUrl)).toContain("/api/v1/message/react"); + const init = calledInit as RequestInit; + expect(init.method).toBe("POST"); + const body = JSON.parse(init.body as string) as Record; + expect(body).toEqual({ + chatGuid: "chat-guid", + selectedMessageGuid: "msg-1", + reaction: "love", + partIndex: 2, + }); + }); +}); + +// --- #34749 regression: downloadAttachment threads policy end-to-end ------- + +describe("client.downloadAttachment (regression for #34749)", () => { + it("threads the client's ssrfPolicy to fetchRemoteMedia", async () => { + mockFetch.mockResolvedValue( + new Response(Buffer.from([1, 2, 3]), { + status: 200, + headers: { "content-type": "image/png" }, + }), + ); + + const client = createBlueBubblesClient({ + serverUrl: "http://localhost:1234", + password: "s3cret", + }); + await client.downloadAttachment({ + attachment: { guid: "att-1", mimeType: "image/png" }, + }); + + expect(fetchRemoteMediaMock).toHaveBeenCalledTimes(1); + const call = fetchRemoteMediaMock.mock.calls[0]?.[0]; + expect(call?.ssrfPolicy).toEqual({ allowPrivateNetwork: true }); + expect(call?.url).toContain("/api/v1/attachment/att-1/download"); + }); + + it("threads the client's ssrfPolicy to the fetchImpl callback (closes #34749 gap)", async () => { + const capturedPolicies: unknown[] = []; + const installPassthrough = createBlueBubblesFetchGuardPassthroughInstaller(); + installPassthrough((policy) => { + capturedPolicies.push(policy); + }); + mockFetch.mockResolvedValue( + new Response(Buffer.from([1, 2, 3]), { + status: 200, + headers: { "content-type": "image/png" }, + }), + ); + + const client = createBlueBubblesClient({ + serverUrl: "http://localhost:1234", + password: "s3cret", + }); + await client.downloadAttachment({ + attachment: { guid: "att-1", mimeType: "image/png" }, + }); + + // fetchImpl ran (the mock runtime delegates to globalThis.fetch via fetchFn), + // which means blueBubblesFetchWithTimeout was called WITH the ssrfPolicy. + // Before this fix, attachments.ts built its fetchImpl without forwarding + // the policy — the guarded path never ran for the actual attachment bytes. + expect(capturedPolicies).toHaveLength(1); + expect(capturedPolicies[0]).toEqual({ allowPrivateNetwork: true }); + }); + + it("throws when attachment guid is missing", async () => { + const client = createBlueBubblesClient({ + serverUrl: "http://localhost:1234", + password: "s3cret", + }); + await expect( + client.downloadAttachment({ attachment: {} as BlueBubblesAttachment }), + ).rejects.toThrow("guid is required"); + }); + + it("surfaces max_bytes error with clear message", async () => { + mockFetch.mockResolvedValue( + new Response(Buffer.alloc(10 * 1024 * 1024), { + status: 200, + headers: { "content-type": "application/octet-stream" }, + }), + ); + const client = createBlueBubblesClient({ + serverUrl: "http://localhost:1234", + password: "s3cret", + }); + await expect( + client.downloadAttachment({ + attachment: { guid: "att-big" }, + maxBytes: 1024, + }), + ).rejects.toThrow(/too large \(limit 1024 bytes\)/); + }); +}); + +// --- Attachment metadata --------------------------------------------------- + +describe("client.getMessageAttachments", () => { + it("fetches and extracts attachment metadata", async () => { + mockFetch.mockResolvedValue( + new Response( + JSON.stringify({ + data: { + attachments: [ + { guid: "att-xyz", transferName: "IMG_0001.JPG", mimeType: "image/jpeg" }, + ], + }, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ); + const client = createBlueBubblesClient({ + serverUrl: "http://localhost:1234", + password: "s3cret", + }); + const result = await client.getMessageAttachments({ messageGuid: "msg-1" }); + expect(result).toHaveLength(1); + expect(result[0]?.guid).toBe("att-xyz"); + expect(result[0]?.mimeType).toBe("image/jpeg"); + expect(String(mockFetch.mock.calls[0]?.[0])).toContain("/api/v1/message/msg-1"); + }); + + it("returns [] on non-ok response rather than throwing", async () => { + mockFetch.mockResolvedValue(new Response("not found", { status: 404 })); + const client = createBlueBubblesClient({ + serverUrl: "http://localhost:1234", + password: "s3cret", + }); + const result = await client.getMessageAttachments({ messageGuid: "missing" }); + expect(result).toEqual([]); + }); +}); + +// --- Cache + invalidation -------------------------------------------------- + +describe("client cache", () => { + it("returns the same instance for the same accountId + baseUrl", () => { + const cfg = { + channels: { + bluebubbles: { serverUrl: "http://localhost:1234", password: "s3cret" }, + }, + } as never; + const a = createBlueBubblesClient({ cfg }); + const b = createBlueBubblesClient({ cfg }); + expect(a).toBe(b); + }); + + it("returns a different instance after invalidate", () => { + const cfg = { + channels: { + bluebubbles: { serverUrl: "http://localhost:1234", password: "s3cret" }, + }, + } as never; + const a = createBlueBubblesClient({ cfg }); + invalidateBlueBubblesClient(a.accountId); + const b = createBlueBubblesClient({ cfg }); + expect(a).not.toBe(b); + }); + + it("cache entry is keyed so different serverUrls cannot collide", () => { + const a = createBlueBubblesClient({ + serverUrl: "http://host-a:1234", + password: "s3cret", + }); + invalidateBlueBubblesClient(a.accountId); + const b = createBlueBubblesClient({ + serverUrl: "http://host-b:1234", + password: "s3cret", + }); + expect(b.baseUrl).toBe("http://host-b:1234"); + }); +}); + +describe("client construction", () => { + it("throws when serverUrl is missing", () => { + expect(() => createBlueBubblesClient({ password: "s3cret" })).toThrow(/serverUrl is required/); + }); + + it("throws when password is missing", () => { + expect(() => createBlueBubblesClient({ serverUrl: "http://localhost:1234" })).toThrow( + /password is required/, + ); + }); + + it("is a BlueBubblesClient instance and exposes read-only policy", () => { + const client = createBlueBubblesClient({ + serverUrl: "http://localhost:1234", + password: "s3cret", + }); + expect(client).toBeInstanceOf(BlueBubblesClient); + // localhost auto-allows (accounts-normalization.ts) → mode 1. + expect(client.getSsrfPolicy()).toEqual({ allowPrivateNetwork: true }); + expect(client.trustedHostname).toBe("localhost"); + expect(client.trustedHostnameIsPrivate).toBe(true); + expect(client.accountId).toBeTruthy(); + }); +}); + +// Reference unused import so lint doesn't complain while we keep parity with +// the existing test-harness module contract (#68xxx). +void _setFetchGuardForTesting; diff --git a/extensions/bluebubbles/src/client.ts b/extensions/bluebubbles/src/client.ts new file mode 100644 index 00000000000..51da58f42b4 --- /dev/null +++ b/extensions/bluebubbles/src/client.ts @@ -0,0 +1,528 @@ +// BlueBubblesClient — consolidated BB API client. +// +// Resolves the BB server URL, auth material, and SSRF policy ONCE at +// construction, then exposes typed operations that cannot omit any of them. +// +// Designed to replace the scattered pattern of each callsite computing its own +// SsrFPolicy and passing it to `blueBubblesFetchWithTimeout`. Related issues: +// - #34749 image attachments blocked by SSRF guard (localhost) +// - #57181 SSRF blocks BB plugin internal API calls +// - #59722 SSRF allowlist doesn't cover reactions +// - #60715 BB health check fails on LAN/private serverUrl +// - #66869 move `?password=` → header auth (future-proofed via AuthStrategy) + +import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; +import { isBlockedHostnameOrIp, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime"; +import { resolveBlueBubblesServerAccount } from "./account-resolve.js"; +import { extractAttachments } from "./monitor-normalize.js"; +import { postMultipartFormData } from "./multipart.js"; +import { resolveRequestUrl } from "./request-url.js"; +import { DEFAULT_ACCOUNT_ID } from "./runtime-api.js"; +import type { OpenClawConfig } from "./runtime-api.js"; +import { getBlueBubblesRuntime } from "./runtime.js"; +import { + blueBubblesFetchWithTimeout, + normalizeBlueBubblesServerUrl, + type BlueBubblesAttachment, +} from "./types.js"; + +const DEFAULT_TIMEOUT_MS = 10_000; +const DEFAULT_ATTACHMENT_MAX_BYTES = 8 * 1024 * 1024; +const DEFAULT_MULTIPART_TIMEOUT_MS = 60_000; + +// --- Auth strategy --------------------------------------------------------- + +/** + * Pluggable authentication for BlueBubbles API requests. Mutates the URL/init + * pair in place before the request is dispatched. + * + * Two built-in strategies are provided: + * - `blueBubblesQueryStringAuth` — today's `?password=...` pattern (default). + * - `blueBubblesHeaderAuth` — header-based auth; flip the default here when + * BB Server ships the header-auth change for #66869. + */ +export interface BlueBubblesAuthStrategy { + decorate(req: { url: URL; init: RequestInit }): void; +} + +export function blueBubblesQueryStringAuth(password: string): BlueBubblesAuthStrategy { + return { + decorate({ url }) { + url.searchParams.set("password", password); + }, + }; +} + +export function blueBubblesHeaderAuth( + password: string, + headerName = "X-BB-Password", +): BlueBubblesAuthStrategy { + return { + decorate({ init }) { + const headers = new Headers(init.headers ?? undefined); + headers.set(headerName, password); + init.headers = headers; + }, + }; +} + +// --- Policy resolution ----------------------------------------------------- + +function safeExtractHostname(baseUrl: string): string | undefined { + try { + const hostname = new URL(normalizeBlueBubblesServerUrl(baseUrl)).hostname.trim(); + return hostname || undefined; + } catch { + return undefined; + } +} + +/** + * Resolve the BB client's SSRF policy at construction time. Three modes: + * + * 1. `{ allowPrivateNetwork: true }` — user explicitly opted in + * (`network.dangerouslyAllowPrivateNetwork: true`). Private/loopback + * addresses are permitted for this client. + * + * 2. `{ allowedHostnames: [trustedHostname] }` — narrow allowlist. Applied + * when we have a parseable hostname AND the user has not explicitly + * opted out (or the hostname isn't private anyway). This is the case + * that closes #34749, #57181, #59722, #60715 for self-hosted BB on + * private/localhost addresses without requiring a full opt-in. + * + * 3. `undefined` — no policy; use the non-SSRF fallback path. Applied only + * when we can't identify a trusted hostname. (#64105) + * + * Prior to this helper, the logic lived inline in `attachments.ts` and was + * inconsistently replicated across 15+ callsites. Resolving once ensures + * every request from a client instance uses the same policy. + */ +export function resolveBlueBubblesClientSsrfPolicy(params: { + baseUrl: string; + allowPrivateNetwork: boolean; + allowPrivateNetworkConfig?: boolean; +}): { + ssrfPolicy: SsrFPolicy | undefined; + trustedHostname?: string; + trustedHostnameIsPrivate: boolean; +} { + const trustedHostname = safeExtractHostname(params.baseUrl); + const trustedHostnameIsPrivate = trustedHostname ? isBlockedHostnameOrIp(trustedHostname) : false; + + if (params.allowPrivateNetwork) { + return { + ssrfPolicy: { allowPrivateNetwork: true }, + trustedHostname, + trustedHostnameIsPrivate, + }; + } + + if ( + trustedHostname && + (params.allowPrivateNetworkConfig !== false || !trustedHostnameIsPrivate) + ) { + return { + ssrfPolicy: { allowedHostnames: [trustedHostname] }, + trustedHostname, + trustedHostnameIsPrivate, + }; + } + + return { ssrfPolicy: undefined, trustedHostname, trustedHostnameIsPrivate }; +} + +// --- Client ---------------------------------------------------------------- + +export type BlueBubblesClientOptions = { + cfg?: OpenClawConfig; + accountId?: string; + serverUrl?: string; + password?: string; + timeoutMs?: number; + authStrategy?: (password: string) => BlueBubblesAuthStrategy; +}; + +type ClientConstructorParams = { + accountId: string; + baseUrl: string; + password: string; + ssrfPolicy: SsrFPolicy | undefined; + trustedHostname: string | undefined; + trustedHostnameIsPrivate: boolean; + defaultTimeoutMs: number; + authStrategy: BlueBubblesAuthStrategy; +}; + +type MediaFetchErrorCode = "max_bytes" | "http_error" | "fetch_failed"; + +function readMediaFetchErrorCode(error: unknown): MediaFetchErrorCode | undefined { + if (!error || typeof error !== "object") { + return undefined; + } + const code = (error as { code?: unknown }).code; + return code === "max_bytes" || code === "http_error" || code === "fetch_failed" + ? code + : undefined; +} + +export class BlueBubblesClient { + readonly accountId: string; + readonly baseUrl: string; + readonly trustedHostname: string | undefined; + readonly trustedHostnameIsPrivate: boolean; + + private readonly password: string; + private readonly ssrfPolicy: SsrFPolicy | undefined; + private readonly defaultTimeoutMs: number; + private readonly authStrategy: BlueBubblesAuthStrategy; + + constructor(params: ClientConstructorParams) { + this.accountId = params.accountId; + this.baseUrl = params.baseUrl; + this.password = params.password; + this.ssrfPolicy = params.ssrfPolicy; + this.trustedHostname = params.trustedHostname; + this.trustedHostnameIsPrivate = params.trustedHostnameIsPrivate; + this.defaultTimeoutMs = params.defaultTimeoutMs; + this.authStrategy = params.authStrategy; + } + + /** + * Read the resolved SSRF policy for this client. Exposed primarily for tests + * and diagnostics; production code should never need to inspect it. + */ + getSsrfPolicy(): SsrFPolicy | undefined { + return this.ssrfPolicy; + } + + // Build an authorized URL+init pair. Auth is applied exactly once per + // request; the SSRF policy is attached by `request()` below. + private buildAuthorizedRequest(params: { path: string; method: string; init?: RequestInit }): { + url: string; + init: RequestInit; + } { + const normalized = normalizeBlueBubblesServerUrl(this.baseUrl); + const url = new URL(params.path, `${normalized}/`); + const init: RequestInit = { ...params.init, method: params.method }; + this.authStrategy.decorate({ url, init }); + return { url: url.toString(), init }; + } + + /** + * Core request method. All typed operations on the client route through + * this method, which handles auth decoration, SSRF policy, and timeout. + */ + async request(params: { + method: string; + path: string; + body?: unknown; + headers?: Record; + timeoutMs?: number; + }): Promise { + const init: RequestInit = {}; + if (params.headers) { + init.headers = { ...params.headers }; + } + if (params.body !== undefined) { + init.headers = { + "Content-Type": "application/json", + ...(init.headers as Record | undefined), + }; + init.body = JSON.stringify(params.body); + } + const prepared = this.buildAuthorizedRequest({ + path: params.path, + method: params.method, + init, + }); + return await blueBubblesFetchWithTimeout( + prepared.url, + prepared.init, + params.timeoutMs ?? this.defaultTimeoutMs, + this.ssrfPolicy, + ); + } + + /** + * JSON request helper. Returns both the response (for status/headers) and + * parsed body (null on non-ok or parse failure — callers check both). + */ + async requestJson(params: { + method: string; + path: string; + body?: unknown; + timeoutMs?: number; + }): Promise<{ response: Response; data: T | null }> { + const response = await this.request(params); + if (!response.ok) { + return { response, data: null }; + } + const raw = await response.json().catch(() => null); + return { response, data: (raw as T | null) ?? null }; + } + + /** + * Multipart POST (attachment send, group icon set). The caller supplies the + * boundary and body parts; the client handles URL construction, auth, and + * SSRF policy. Timeout defaults to 60s because uploads can be large. + */ + async requestMultipart(params: { + path: string; + boundary: string; + parts: Uint8Array[]; + timeoutMs?: number; + }): Promise { + const prepared = this.buildAuthorizedRequest({ + path: params.path, + method: "POST", + init: {}, + }); + return await postMultipartFormData({ + url: prepared.url, + boundary: params.boundary, + parts: params.parts, + timeoutMs: params.timeoutMs ?? DEFAULT_MULTIPART_TIMEOUT_MS, + ssrfPolicy: this.ssrfPolicy, + }); + } + + // --- Probe operations ---------------------------------------------------- + + /** GET /api/v1/ping — health check. Raw response for status inspection. */ + async ping(params: { timeoutMs?: number } = {}): Promise { + return await this.request({ + method: "GET", + path: "/api/v1/ping", + timeoutMs: params.timeoutMs, + }); + } + + /** GET /api/v1/server/info — server/OS/Private-API metadata. */ + async getServerInfo(params: { timeoutMs?: number } = {}): Promise { + return await this.request({ + method: "GET", + path: "/api/v1/server/info", + timeoutMs: params.timeoutMs, + }); + } + + // --- Reactions (fixes #59722) ------------------------------------------- + + /** + * POST /api/v1/message/react. Uses the same SSRF policy as every other + * operation on this client — closing the gap where `reactions.ts` passed + * `{}` (always guarded, always blocks private IPs) while other callsites + * used mode-aware policies. + */ + async react(params: { + chatGuid: string; + selectedMessageGuid: string; + reaction: string; + partIndex?: number; + timeoutMs?: number; + }): Promise { + return await this.request({ + method: "POST", + path: "/api/v1/message/react", + body: { + chatGuid: params.chatGuid, + selectedMessageGuid: params.selectedMessageGuid, + reaction: params.reaction, + partIndex: typeof params.partIndex === "number" ? params.partIndex : 0, + }, + timeoutMs: params.timeoutMs, + }); + } + + // --- Attachments (fixes #34749) ----------------------------------------- + + /** + * GET /api/v1/message/{guid} to read attachment metadata. BlueBubbles may + * fire `new-message` before attachment indexing completes, so this re-reads + * after a delay. (#65430, #67437) + */ + async getMessageAttachments(params: { + messageGuid: string; + timeoutMs?: number; + }): Promise { + const { response, data } = await this.requestJson<{ + data?: Record; + }>({ + method: "GET", + path: `/api/v1/message/${encodeURIComponent(params.messageGuid)}`, + timeoutMs: params.timeoutMs, + }); + if (!response.ok || !data?.data) { + return []; + } + return extractAttachments(data.data); + } + + /** + * Download an attachment via the channel media fetcher. Unlike the legacy + * helper, the SSRF policy is threaded to BOTH `fetchRemoteMedia` AND the + * `fetchImpl` callback — closing #34749 where the callback silently fell + * back to the unguarded fetch path regardless of the outer policy. + * + * Note: the actual SSRF check still happens upstream in `fetchRemoteMedia`. + * Passing `ssrfPolicy` to `blueBubblesFetchWithTimeout` in the callback + * keeps it in the guarded path if the host needs re-validation (e.g. on a + * BB Server that issues 302 redirects to a different host). + */ + async downloadAttachment(params: { + attachment: BlueBubblesAttachment; + maxBytes?: number; + timeoutMs?: number; + }): Promise<{ buffer: Uint8Array; contentType?: string }> { + const guid = params.attachment.guid?.trim(); + if (!guid) { + throw new Error("BlueBubbles attachment guid is required"); + } + const maxBytes = + typeof params.maxBytes === "number" ? params.maxBytes : DEFAULT_ATTACHMENT_MAX_BYTES; + const prepared = this.buildAuthorizedRequest({ + path: `/api/v1/attachment/${encodeURIComponent(guid)}/download`, + method: "GET", + init: {}, + }); + const clientSsrfPolicy = this.ssrfPolicy; + const effectiveTimeoutMs = params.timeoutMs ?? this.defaultTimeoutMs; + + try { + const fetched = await getBlueBubblesRuntime().channel.media.fetchRemoteMedia({ + url: prepared.url, + filePathHint: params.attachment.transferName ?? params.attachment.guid ?? "attachment", + maxBytes, + ssrfPolicy: clientSsrfPolicy, + fetchImpl: async (input, init) => + await blueBubblesFetchWithTimeout( + resolveRequestUrl(input), + { ...init, method: init?.method ?? "GET" }, + effectiveTimeoutMs, + clientSsrfPolicy, + ), + }); + return { + buffer: new Uint8Array(fetched.buffer), + contentType: fetched.contentType ?? params.attachment.mimeType ?? undefined, + }; + } catch (error) { + if (readMediaFetchErrorCode(error) === "max_bytes") { + throw new Error(`BlueBubbles attachment too large (limit ${maxBytes} bytes)`, { + cause: error, + }); + } + throw new Error(`BlueBubbles attachment download failed: ${formatErrorMessage(error)}`, { + cause: error, + }); + } + } +} + +// --- Factory and cache ----------------------------------------------------- + +type CachedClientEntry = { + client: BlueBubblesClient; + /** Fingerprint of {baseUrl, password} — cache hit requires full match. */ + fingerprint: string; +}; +const clientFingerprints = new Map(); + +function buildClientFingerprint(params: { baseUrl: string; password: string }): string { + return `${params.baseUrl}|${params.password}`; +} + +/** + * Get or create a `BlueBubblesClient` for one BB account. The client is cached + * by `accountId` — the next call with the same account AND same {baseUrl, + * password} returns the existing instance. Password or URL change rebuilds. + * Call `invalidateBlueBubblesClient(accountId)` from account config reload + * paths to evict explicitly. + */ +export function createBlueBubblesClient(opts: BlueBubblesClientOptions = {}): BlueBubblesClient { + const resolved = resolveBlueBubblesServerAccount({ + cfg: opts.cfg, + accountId: opts.accountId, + serverUrl: opts.serverUrl, + password: opts.password, + }); + const cacheKey = resolved.accountId || DEFAULT_ACCOUNT_ID; + const fingerprint = buildClientFingerprint({ + baseUrl: resolved.baseUrl, + password: resolved.password, + }); + const cached = clientFingerprints.get(cacheKey); + if (cached && cached.fingerprint === fingerprint) { + return cached.client; + } + + const policyResult = resolveBlueBubblesClientSsrfPolicy({ + baseUrl: resolved.baseUrl, + allowPrivateNetwork: resolved.allowPrivateNetwork, + allowPrivateNetworkConfig: resolved.allowPrivateNetworkConfig, + }); + const authFactory = opts.authStrategy ?? blueBubblesQueryStringAuth; + + const client = new BlueBubblesClient({ + accountId: cacheKey, + baseUrl: resolved.baseUrl, + password: resolved.password, + ssrfPolicy: policyResult.ssrfPolicy, + trustedHostname: policyResult.trustedHostname, + trustedHostnameIsPrivate: policyResult.trustedHostnameIsPrivate, + defaultTimeoutMs: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS, + authStrategy: authFactory(resolved.password), + }); + clientFingerprints.set(cacheKey, { client, fingerprint }); + return client; +} + +/** Evict a cached client by account id. Called from account config reload paths. */ +export function invalidateBlueBubblesClient(accountId?: string): void { + const key = accountId || DEFAULT_ACCOUNT_ID; + clientFingerprints.delete(key); +} + +/** @internal Clear the whole client cache. Test helper. */ +export function clearBlueBubblesClientCache(): void { + clientFingerprints.clear(); +} + +/** + * Build a BlueBubblesClient from a pre-resolved `{baseUrl, password, + * allowPrivateNetwork}` tuple, skipping the account/config resolution path. + * + * Used by low-level helpers (`probe.ts`, `catchup.ts`, `history.ts`, etc.) + * that are called with the resolved tuple rather than a full config bag. + * Migrated callers pass their existing booleans straight through — the + * three-mode policy resolution then runs exactly once here. + * + * Uncached — intended for short-lived callsites. Prefer `createBlueBubblesClient` + * when a `cfg` + `accountId` are available. + */ +export function createBlueBubblesClientFromParts(params: { + baseUrl: string; + password: string; + allowPrivateNetwork: boolean; + allowPrivateNetworkConfig?: boolean; + accountId?: string; + timeoutMs?: number; + authStrategy?: (password: string) => BlueBubblesAuthStrategy; +}): BlueBubblesClient { + const policyResult = resolveBlueBubblesClientSsrfPolicy({ + baseUrl: params.baseUrl, + allowPrivateNetwork: params.allowPrivateNetwork, + allowPrivateNetworkConfig: params.allowPrivateNetworkConfig, + }); + const authFactory = params.authStrategy ?? blueBubblesQueryStringAuth; + return new BlueBubblesClient({ + accountId: params.accountId || DEFAULT_ACCOUNT_ID, + baseUrl: params.baseUrl, + password: params.password, + ssrfPolicy: policyResult.ssrfPolicy, + trustedHostname: policyResult.trustedHostname, + trustedHostnameIsPrivate: policyResult.trustedHostnameIsPrivate, + defaultTimeoutMs: params.timeoutMs ?? DEFAULT_TIMEOUT_MS, + authStrategy: authFactory(params.password), + }); +} diff --git a/extensions/bluebubbles/src/history.ts b/extensions/bluebubbles/src/history.ts index 6c41d884b3c..988e8dae5c9 100644 --- a/extensions/bluebubbles/src/history.ts +++ b/extensions/bluebubbles/src/history.ts @@ -1,6 +1,6 @@ import { resolveBlueBubblesServerAccount } from "./account-resolve.js"; +import { createBlueBubblesClientFromParts } from "./client.js"; import type { OpenClawConfig } from "./runtime-api.js"; -import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js"; export type BlueBubblesHistoryEntry = { sender: string; @@ -89,7 +89,12 @@ export async function fetchBlueBubblesHistory( } catch { return { entries: [], resolved: false }; } - const ssrfPolicy = allowPrivateNetwork ? { allowPrivateNetwork: true } : {}; + const client = createBlueBubblesClientFromParts({ + baseUrl, + password, + allowPrivateNetwork, + timeoutMs: opts.timeoutMs ?? 10000, + }); // Try different common API patterns for fetching messages const possiblePaths = [ @@ -100,13 +105,11 @@ export async function fetchBlueBubblesHistory( for (const path of possiblePaths) { try { - const url = buildBlueBubblesApiUrl({ baseUrl, path, password }); - const res = await blueBubblesFetchWithTimeout( - url, - { method: "GET" }, - opts.timeoutMs ?? 10000, - ssrfPolicy, - ); + const res = await client.request({ + method: "GET", + path, + timeoutMs: opts.timeoutMs ?? 10000, + }); if (!res.ok) { continue; // Try next path diff --git a/extensions/bluebubbles/src/monitor-processing.ts b/extensions/bluebubbles/src/monitor-processing.ts index 0e9016d73de..41254fe5abd 100644 --- a/extensions/bluebubbles/src/monitor-processing.ts +++ b/extensions/bluebubbles/src/monitor-processing.ts @@ -14,6 +14,7 @@ import { fetchBlueBubblesMessageAttachments, } from "./attachments.js"; import { markBlueBubblesChatRead, sendBlueBubblesTyping } from "./chat.js"; +import { createBlueBubblesClientFromParts } from "./client.js"; import { resolveBlueBubblesConversationRoute } from "./conversation-route.js"; import { fetchBlueBubblesHistory } from "./history.js"; import { @@ -79,7 +80,6 @@ import { isAllowedBlueBubblesSender, normalizeBlueBubblesHandle, } from "./targets.js"; -import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js"; const DEFAULT_TEXT_LIMIT = 4000; const invalidAckReactions = new Set(); @@ -109,10 +109,6 @@ function normalizeSnippet(value: string): string { type BlueBubblesChatRecord = Record; -function blueBubblesPolicy(allowPrivateNetwork: boolean | undefined) { - return allowPrivateNetwork ? { allowPrivateNetwork: true } : undefined; -} - function extractBlueBubblesChatGuid(chat: BlueBubblesChatRecord): string | undefined { const candidates = [chat.chatGuid, chat.guid, chat.chat_guid]; for (const candidate of candidates) { @@ -161,25 +157,22 @@ async function queryBlueBubblesChats(params: { limit: number; allowPrivateNetwork?: boolean; }): Promise { - const url = buildBlueBubblesApiUrl({ + const client = createBlueBubblesClientFromParts({ baseUrl: params.baseUrl, - path: "/api/v1/chat/query", password: params.password, + allowPrivateNetwork: params.allowPrivateNetwork === true, + timeoutMs: params.timeoutMs, }); - const res = await blueBubblesFetchWithTimeout( - url, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - limit: params.limit, - offset: params.offset, - with: ["participants"], - }), + const res = await client.request({ + method: "POST", + path: "/api/v1/chat/query", + body: { + limit: params.limit, + offset: params.offset, + with: ["participants"], }, - params.timeoutMs, - blueBubblesPolicy(params.allowPrivateNetwork), - ); + timeoutMs: params.timeoutMs, + }); if (!res.ok) { return []; } diff --git a/extensions/bluebubbles/src/monitor.test.ts b/extensions/bluebubbles/src/monitor.test.ts index e116b60027b..3cc14fe304a 100644 --- a/extensions/bluebubbles/src/monitor.test.ts +++ b/extensions/bluebubbles/src/monitor.test.ts @@ -22,12 +22,14 @@ import { setBlueBubblesParticipantContactDepsForTest, } from "./participant-contact-names.js"; import type { OpenClawConfig, PluginRuntime } from "./runtime-api.js"; +import { createBlueBubblesFetchGuardPassthroughInstaller } from "./test-harness.js"; import { createBlueBubblesMonitorTestRuntime, EMPTY_DISPATCH_RESULT, resetBlueBubblesMonitorTestState, type DispatchReplyParams, } from "./test-support/monitor-test-support.js"; +import { _setFetchGuardForTesting } from "./types.js"; // Mock dependencies vi.mock("./send.js", () => ({ @@ -255,8 +257,16 @@ describe("BlueBubbles webhook monitor", () => { return handled; } + const installFetchGuardPassthrough = createBlueBubblesFetchGuardPassthroughInstaller(); + beforeEach(() => { vi.stubGlobal("fetch", mockFetch); + // The BlueBubblesClient now routes every BB API call through the SSRF + // guard (mode-2 allowlist for configured hostnames). Install a passthrough + // that wraps `globalThis.fetch` (our stubbed mockFetch) in a real Response + // so guarded callers get the same mocked behavior the pre-migration + // callsites did. (#34749, #59722) + installFetchGuardPassthrough(); mockFetch.mockReset(); mockFetch.mockResolvedValue({ ok: true, @@ -284,6 +294,7 @@ describe("BlueBubbles webhook monitor", () => { setBlueBubblesParticipantContactDepsForTest(); vi.useRealTimers(); vi.unstubAllGlobals(); + _setFetchGuardForTesting(null); }); describe("DM pairing behavior vs allowFrom", () => { diff --git a/extensions/bluebubbles/src/monitor.webhook-auth.test.ts b/extensions/bluebubbles/src/monitor.webhook-auth.test.ts index 29c7534b9d2..f5bfa57a279 100644 --- a/extensions/bluebubbles/src/monitor.webhook-auth.test.ts +++ b/extensions/bluebubbles/src/monitor.webhook-auth.test.ts @@ -20,12 +20,14 @@ import { type WebhookRequestParams, } from "./monitor.webhook.test-helpers.js"; import type { OpenClawConfig, PluginRuntime } from "./runtime-api.js"; +import { createBlueBubblesFetchGuardPassthroughInstaller } from "./test-harness.js"; import { createBlueBubblesMonitorTestRuntime, EMPTY_DISPATCH_RESULT, resetBlueBubblesMonitorTestState, type DispatchReplyParams, } from "./test-support/monitor-test-support.js"; +import { _setFetchGuardForTesting } from "./types.js"; const { TEST_WEBHOOK_RATE_LIMIT_MAX_REQUESTS } = vi.hoisted(() => ({ TEST_WEBHOOK_RATE_LIMIT_MAX_REQUESTS: 3, @@ -168,9 +170,13 @@ function createMockRuntime(): PluginRuntime { describe("BlueBubbles webhook monitor", () => { let unregister: () => void; + const installFetchGuardPassthrough = createBlueBubblesFetchGuardPassthroughInstaller(); beforeEach(() => { vi.stubGlobal("fetch", mockFetch); + // See monitor.test.ts for rationale — BlueBubblesClient routes every BB + // API call through the SSRF guard now. (#34749, #59722) + installFetchGuardPassthrough(); mockFetch.mockReset(); mockFetch.mockResolvedValue({ ok: true, @@ -191,6 +197,7 @@ describe("BlueBubbles webhook monitor", () => { afterEach(() => { unregister?.(); vi.unstubAllGlobals(); + _setFetchGuardForTesting(null); }); function setupWebhookTarget(params?: { diff --git a/extensions/bluebubbles/src/probe.ts b/extensions/bluebubbles/src/probe.ts index ce3e6d442a7..638c3ae59d3 100644 --- a/extensions/bluebubbles/src/probe.ts +++ b/extensions/bluebubbles/src/probe.ts @@ -1,8 +1,8 @@ import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; +import { createBlueBubblesClientFromParts } from "./client.js"; import type { BaseProbeResult } from "./runtime-api.js"; import { normalizeSecretInputString } from "./secret-input.js"; -import { buildBlueBubblesApiUrl, blueBubblesFetchWithTimeout } from "./types.js"; export type BlueBubblesProbe = BaseProbeResult & { status?: number | null; @@ -47,15 +47,14 @@ export async function fetchBlueBubblesServerInfo(params: { return cached.info; } - const ssrfPolicy = params.allowPrivateNetwork ? { allowPrivateNetwork: true } : {}; - const url = buildBlueBubblesApiUrl({ baseUrl, path: "/api/v1/server/info", password }); + const client = createBlueBubblesClientFromParts({ + baseUrl, + password, + allowPrivateNetwork: params.allowPrivateNetwork === true, + timeoutMs: params.timeoutMs ?? 5000, + }); try { - const res = await blueBubblesFetchWithTimeout( - url, - { method: "GET" }, - params.timeoutMs ?? 5000, - ssrfPolicy, - ); + const res = await client.getServerInfo({ timeoutMs: params.timeoutMs ?? 5000 }); if (!res.ok) { return null; } @@ -153,15 +152,14 @@ export async function probeBlueBubbles(params: { if (!password) { return { ok: false, error: "password not configured" }; } - const probeSsrfPolicy = params.allowPrivateNetwork ? { allowPrivateNetwork: true } : {}; - const url = buildBlueBubblesApiUrl({ baseUrl, path: "/api/v1/ping", password }); + const client = createBlueBubblesClientFromParts({ + baseUrl, + password, + allowPrivateNetwork: params.allowPrivateNetwork === true, + timeoutMs: params.timeoutMs, + }); try { - const res = await blueBubblesFetchWithTimeout( - url, - { method: "GET" }, - params.timeoutMs, - probeSsrfPolicy, - ); + const res = await client.ping({ timeoutMs: params.timeoutMs }); if (!res.ok) { return { ok: false, status: res.status, error: `HTTP ${res.status}` }; } diff --git a/extensions/bluebubbles/src/reactions.ts b/extensions/bluebubbles/src/reactions.ts index 9d864500f5e..d124ad4589a 100644 --- a/extensions/bluebubbles/src/reactions.ts +++ b/extensions/bluebubbles/src/reactions.ts @@ -1,8 +1,7 @@ import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; -import { resolveBlueBubblesServerAccount } from "./account-resolve.js"; +import { createBlueBubblesClient } from "./client.js"; import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js"; import type { OpenClawConfig } from "./runtime-api.js"; -import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js"; export type BlueBubblesReactionOpts = { serverUrl?: string; @@ -112,10 +111,6 @@ const REACTION_EMOJIS = new Map([ ["?", "question"], ]); -function resolveAccount(params: BlueBubblesReactionOpts) { - return resolveBlueBubblesServerAccount(params); -} - export function normalizeBlueBubblesReactionInput(emoji: string, remove?: boolean): string { const trimmed = emoji.trim(); if (!trimmed) { @@ -150,34 +145,22 @@ export async function sendBlueBubblesReaction(params: { throw new Error("BlueBubbles reaction requires messageGuid."); } const reaction = normalizeBlueBubblesReactionInput(params.emoji, params.remove); - const { baseUrl, password, accountId, allowPrivateNetwork } = resolveAccount(params.opts ?? {}); - if (getCachedBlueBubblesPrivateApiStatus(accountId) === false) { + const client = createBlueBubblesClient(params.opts ?? {}); + if (getCachedBlueBubblesPrivateApiStatus(client.accountId) === false) { throw new Error( "BlueBubbles reaction requires Private API, but it is disabled on the BlueBubbles server.", ); } - const url = buildBlueBubblesApiUrl({ - baseUrl, - path: "/api/v1/message/react", - password, - }); - const payload = { + // Go through the client's typed `react` method — it uses the same SSRF policy + // as every other client call, eliminating the asymmetric `{}` vs + // `{ allowedHostnames }` path that caused #59722. + const res = await client.react({ chatGuid, selectedMessageGuid: messageGuid, reaction, partIndex: typeof params.partIndex === "number" ? params.partIndex : 0, - }; - const ssrfPolicy = allowPrivateNetwork ? { allowPrivateNetwork: true } : {}; - const res = await blueBubblesFetchWithTimeout( - url, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }, - params.opts?.timeoutMs, - ssrfPolicy, - ); + timeoutMs: params.opts?.timeoutMs, + }); if (!res.ok) { const errorText = await res.text(); throw new Error(`BlueBubbles reaction failed (${res.status}): ${errorText || "unknown"}`); diff --git a/extensions/bluebubbles/src/send.ts b/extensions/bluebubbles/src/send.ts index eec056a467b..027714bc563 100644 --- a/extensions/bluebubbles/src/send.ts +++ b/extensions/bluebubbles/src/send.ts @@ -6,6 +6,7 @@ import { stripMarkdown, } from "openclaw/plugin-sdk/text-runtime"; import { resolveBlueBubblesServerAccount } from "./account-resolve.js"; +import { createBlueBubblesClient, createBlueBubblesClientFromParts } from "./client.js"; import { fetchBlueBubblesServerInfo, getCachedBlueBubblesPrivateApiStatus, @@ -15,16 +16,7 @@ import type { OpenClawConfig } from "./runtime-api.js"; import { warnBlueBubbles } from "./runtime.js"; import { extractBlueBubblesMessageId, resolveBlueBubblesSendTarget } from "./send-helpers.js"; import { extractHandleFromChatGuid, normalizeBlueBubblesHandle } from "./targets.js"; -import { - blueBubblesFetchWithTimeout, - buildBlueBubblesApiUrl, - type BlueBubblesSendTarget, - type SsrFPolicy, -} from "./types.js"; - -function blueBubblesPolicy(allowPrivateNetwork: boolean | undefined): SsrFPolicy { - return allowPrivateNetwork ? { allowPrivateNetwork: true } : {}; -} +import { type BlueBubblesSendTarget } from "./types.js"; export type BlueBubblesSendOpts = { serverUrl?: string; @@ -206,25 +198,22 @@ async function queryChats(params: { limit: number; allowPrivateNetwork?: boolean; }): Promise { - const url = buildBlueBubblesApiUrl({ + const client = createBlueBubblesClientFromParts({ baseUrl: params.baseUrl, - path: "/api/v1/chat/query", password: params.password, + allowPrivateNetwork: params.allowPrivateNetwork === true, + timeoutMs: params.timeoutMs, }); - const res = await blueBubblesFetchWithTimeout( - url, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - limit: params.limit, - offset: params.offset, - with: ["participants"], - }), + const res = await client.request({ + method: "POST", + path: "/api/v1/chat/query", + body: { + limit: params.limit, + offset: params.offset, + with: ["participants"], }, - params.timeoutMs, - blueBubblesPolicy(params.allowPrivateNetwork), - ); + timeoutMs: params.timeoutMs, + }); if (!res.ok) { return []; } @@ -341,26 +330,23 @@ export async function createChatForHandle(params: { timeoutMs?: number; allowPrivateNetwork?: boolean; }): Promise<{ chatGuid: string | null; messageId: string }> { - const url = buildBlueBubblesApiUrl({ + const client = createBlueBubblesClientFromParts({ baseUrl: params.baseUrl, - path: "/api/v1/chat/new", password: params.password, + allowPrivateNetwork: params.allowPrivateNetwork === true, + timeoutMs: params.timeoutMs, }); const payload = { addresses: [params.address], message: params.message ?? "", tempGuid: `temp-${crypto.randomUUID()}`, }; - const res = await blueBubblesFetchWithTimeout( - url, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }, - params.timeoutMs, - blueBubblesPolicy(params.allowPrivateNetwork), - ); + const res = await client.request({ + method: "POST", + path: "/api/v1/chat/new", + body: payload, + timeoutMs: params.timeoutMs, + }); if (!res.ok) { const errorText = await res.text(); if ( @@ -539,21 +525,18 @@ export async function sendMessageBlueBubbles( payload.effectId = effectId; } - const url = buildBlueBubblesApiUrl({ - baseUrl, - path: "/api/v1/message/text", - password, + const client = createBlueBubblesClient({ + cfg: opts.cfg ?? {}, + accountId: opts.accountId, + serverUrl: opts.serverUrl, + password: opts.password, + }); + const res = await client.request({ + method: "POST", + path: "/api/v1/message/text", + body: payload, + timeoutMs: opts.timeoutMs, }); - const res = await blueBubblesFetchWithTimeout( - url, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }, - opts.timeoutMs, - blueBubblesPolicy(allowPrivateNetwork), - ); if (!res.ok) { const errorText = await res.text(); throw new Error(`BlueBubbles send failed (${res.status}): ${errorText || "unknown"}`);