// 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 { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id"; 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 type { OpenClawConfig } from "./runtime-api.js"; import { getBlueBubblesRuntime } from "./runtime.js"; import {
blueBubblesFetchWithTimeout,
normalizeBlueBubblesServerUrl,
type BlueBubblesAttachment,
} from "./types.js";
/** * 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 { /** * Stable identifier for this strategy. Used by the client cache fingerprint * so two clients for the same account + credential that differ only in auth * strategy don't silently collapse onto the same cached instance. * (Greptile #68234 P2)
*/
readonly id: string;
decorate(req: { url: URL; init: RequestInit }): void;
}
/** * Resolve the BB client's SSRF policy at construction time. Three modes — * all of which go through `fetchWithSsrFGuard`; we never hand back a policy * that skips the guard: * * 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. `{}` — guarded with the default-deny policy. Applied when we can't * produce a valid allowlist (opt-out on a private hostname, or an * unparseable baseUrl). Previously returned `undefined` and skipped * the guard entirely, which was an SSRF bypass when a user explicitly * opted out of private-network access. Aisle #68234 found this. * * 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;
trustedHostname?: string;
trustedHostnameIsPrivate: boolean;
} { const trustedHostname = safeExtractHostname(params.baseUrl); const trustedHostnameIsPrivate = trustedHostname ? isBlockedHostnameOrIp(trustedHostname) : false;
/** * Read the resolved SSRF policy for this client. Exposed primarily for tests * and diagnostics; production code should never need to inspect it.
*/
getSsrfPolicy(): SsrFPolicy { returnthis.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<string, string>;
timeoutMs?: number;
}): Promise<Response> { const init: RequestInit = {}; if (params.headers) {
init.headers = { ...params.headers };
} if (params.body !== undefined) {
init.headers = { "Content-Type": "application/json",
...(init.headers as Record<string, string> | 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: unknown }> { const response = await this.request(params); if (!response.ok) { return { response, data: null };
} const raw: unknown = await response.json().catch(() => null); return { response, data: raw };
}
/** * 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. * * Auth-decorated headers from `prepared.init` are forwarded via `extraHeaders` * so header-auth strategies keep working on multipart paths. (Greptile #68234 P1)
*/
async requestMultipart(params: {
path: string;
boundary: string;
parts: Uint8Array[];
timeoutMs?: number;
}): Promise<Response> { 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,
extraHeaders: prepared.init.headers,
});
}
/** * 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<BlueBubblesAttachment[]> { const { response, data } = await this.requestJson({
method: "GET",
path: `/api/v1/message/${encodeURIComponent(params.messageGuid)}`,
timeoutMs: params.timeoutMs,
}); if (!response.ok || typeof data !== "object" || data === null) { return [];
} const inner = (data as { data?: unknown }).data; if (typeof inner !== "object" || inner === null) { return [];
} return extractAttachments(inner as Record<string, unknown>);
}
/** * 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) { thrownew 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; // Auth-decorated headers from buildAuthorizedRequest (for header-auth // strategies) must flow through the fetchImpl callback too, otherwise // the runtime might dispatch with only its own default headers. Merge // prepared.init.headers with any headers the runtime supplies; runtime // headers (typically Range for partial reads) win on conflict. // (Greptile #68234 P1) const preparedHeaders = prepared.init.headers;
Die Informationen auf dieser Webseite wurden
nach bestem Wissen sorgfältig zusammengestellt. Es wird jedoch weder Vollständigkeit, noch Richtigkeit,
noch Qualität der bereit gestellten Informationen zugesichert.
Bemerkung:
Die farbliche Syntaxdarstellung und die Messung sind noch experimentell.