mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-04 05:42:02 +00:00
* fix(extensions): route fetch calls through fetchWithSsrFGuard Replace raw fetch() with fetchWithSsrFGuard in BlueBubbles, Mattermost, Nextcloud Talk, and Thread Ownership extensions so outbound requests go through the shared DNS-pinning and network-policy layer. BlueBubbles: thread allowPrivateNetwork from account config through all fetch call sites (send, chat, reactions, history, probe, attachments, multipart). Add _setFetchGuardForTesting hook for test overrides. Mattermost: add guardedFetchImpl wrapper in createMattermostClient that buffers the response body before releasing the dispatcher. Handle null-body status codes (204/304). Nextcloud Talk: wrap both sendMessage and sendReaction with fetchWithSsrFGuard and try/finally release. Thread Ownership: add fetchWithSsrFGuard and ssrfPolicyFromAllowPrivateNetwork to the plugin SDK surface; use allowPrivateNetwork:true for the Docker-internal forwarder. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(extensions): improve null-body handling and test harness cleanup Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(bluebubbles): default to strict SSRF policy when allowPrivateNetwork is unset Callers that omit allowPrivateNetwork previously got undefined policy, which caused blueBubblesFetchWithTimeout to fall through to raw fetch and bypass the SSRF guard entirely. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(bluebubbles): thread allowPrivateNetwork through action and monitor call sites Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(mattermost,nextcloud-talk): add allowPrivateNetwork config for self-hosted/LAN deployments * fix: regenerate config docs baseline for new allowPrivateNetwork fields --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
179 lines
5.5 KiB
TypeScript
179 lines
5.5 KiB
TypeScript
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;
|
|
};
|
|
|
|
export type BlueBubblesServerInfo = {
|
|
os_version?: string;
|
|
server_version?: string;
|
|
private_api?: boolean;
|
|
helper_connected?: boolean;
|
|
proxy_service?: string;
|
|
detected_icloud?: string;
|
|
computer_id?: string;
|
|
};
|
|
|
|
/** Cache server info by account ID to avoid repeated API calls.
|
|
* Size-capped to prevent unbounded growth (#4948). */
|
|
const MAX_SERVER_INFO_CACHE_SIZE = 64;
|
|
const serverInfoCache = new Map<string, { info: BlueBubblesServerInfo; expires: number }>();
|
|
const CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
|
|
|
function buildCacheKey(accountId?: string): string {
|
|
return accountId?.trim() || "default";
|
|
}
|
|
|
|
/**
|
|
* Fetch server info from BlueBubbles API and cache it.
|
|
* Returns cached result if available and not expired.
|
|
*/
|
|
export async function fetchBlueBubblesServerInfo(params: {
|
|
baseUrl?: string | null;
|
|
password?: string | null;
|
|
accountId?: string;
|
|
timeoutMs?: number;
|
|
allowPrivateNetwork?: boolean;
|
|
}): Promise<BlueBubblesServerInfo | null> {
|
|
const baseUrl = normalizeSecretInputString(params.baseUrl);
|
|
const password = normalizeSecretInputString(params.password);
|
|
if (!baseUrl || !password) {
|
|
return null;
|
|
}
|
|
|
|
const cacheKey = buildCacheKey(params.accountId);
|
|
const cached = serverInfoCache.get(cacheKey);
|
|
if (cached && cached.expires > Date.now()) {
|
|
return cached.info;
|
|
}
|
|
|
|
const ssrfPolicy = params.allowPrivateNetwork ? { allowPrivateNetwork: true } : {};
|
|
const url = buildBlueBubblesApiUrl({ baseUrl, path: "/api/v1/server/info", password });
|
|
try {
|
|
const res = await blueBubblesFetchWithTimeout(
|
|
url,
|
|
{ method: "GET" },
|
|
params.timeoutMs ?? 5000,
|
|
ssrfPolicy,
|
|
);
|
|
if (!res.ok) {
|
|
return null;
|
|
}
|
|
const payload = (await res.json().catch(() => null)) as Record<string, unknown> | null;
|
|
const data = payload?.data as BlueBubblesServerInfo | undefined;
|
|
if (data) {
|
|
serverInfoCache.set(cacheKey, { info: data, expires: Date.now() + CACHE_TTL_MS });
|
|
// Evict oldest entries if cache exceeds max size
|
|
if (serverInfoCache.size > MAX_SERVER_INFO_CACHE_SIZE) {
|
|
const oldest = serverInfoCache.keys().next().value;
|
|
if (oldest !== undefined) {
|
|
serverInfoCache.delete(oldest);
|
|
}
|
|
}
|
|
}
|
|
return data ?? null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get cached server info synchronously (for use in describeMessageTool).
|
|
* Returns null if not cached or expired.
|
|
*/
|
|
export function getCachedBlueBubblesServerInfo(accountId?: string): BlueBubblesServerInfo | null {
|
|
const cacheKey = buildCacheKey(accountId);
|
|
const cached = serverInfoCache.get(cacheKey);
|
|
if (cached && cached.expires > Date.now()) {
|
|
return cached.info;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Read cached private API capability for a BlueBubbles account.
|
|
* Returns null when capability is unknown (for example, before first probe).
|
|
*/
|
|
export function getCachedBlueBubblesPrivateApiStatus(accountId?: string): boolean | null {
|
|
const info = getCachedBlueBubblesServerInfo(accountId);
|
|
if (!info || typeof info.private_api !== "boolean") {
|
|
return null;
|
|
}
|
|
return info.private_api;
|
|
}
|
|
|
|
export function isBlueBubblesPrivateApiStatusEnabled(status: boolean | null): boolean {
|
|
return status === true;
|
|
}
|
|
|
|
export function isBlueBubblesPrivateApiEnabled(accountId?: string): boolean {
|
|
return isBlueBubblesPrivateApiStatusEnabled(getCachedBlueBubblesPrivateApiStatus(accountId));
|
|
}
|
|
|
|
/**
|
|
* Parse macOS version string (e.g., "15.0.1" or "26.0") into major version number.
|
|
*/
|
|
export function parseMacOSMajorVersion(version?: string | null): number | null {
|
|
if (!version) {
|
|
return null;
|
|
}
|
|
const match = /^(\d+)/.exec(version.trim());
|
|
return match ? Number.parseInt(match[1], 10) : null;
|
|
}
|
|
|
|
/**
|
|
* Check if the cached server info indicates macOS 26 or higher.
|
|
* Returns false if no cached info is available (fail open for action listing).
|
|
*/
|
|
export function isMacOS26OrHigher(accountId?: string): boolean {
|
|
const info = getCachedBlueBubblesServerInfo(accountId);
|
|
if (!info?.os_version) {
|
|
return false;
|
|
}
|
|
const major = parseMacOSMajorVersion(info.os_version);
|
|
return major !== null && major >= 26;
|
|
}
|
|
|
|
/** Clear the server info cache (for testing) */
|
|
export function clearServerInfoCache(): void {
|
|
serverInfoCache.clear();
|
|
}
|
|
|
|
export async function probeBlueBubbles(params: {
|
|
baseUrl?: string | null;
|
|
password?: string | null;
|
|
timeoutMs?: number;
|
|
allowPrivateNetwork?: boolean;
|
|
}): Promise<BlueBubblesProbe> {
|
|
const baseUrl = normalizeSecretInputString(params.baseUrl);
|
|
const password = normalizeSecretInputString(params.password);
|
|
if (!baseUrl) {
|
|
return { ok: false, error: "serverUrl not configured" };
|
|
}
|
|
if (!password) {
|
|
return { ok: false, error: "password not configured" };
|
|
}
|
|
const probeSsrfPolicy = params.allowPrivateNetwork ? { allowPrivateNetwork: true } : {};
|
|
const url = buildBlueBubblesApiUrl({ baseUrl, path: "/api/v1/ping", password });
|
|
try {
|
|
const res = await blueBubblesFetchWithTimeout(
|
|
url,
|
|
{ method: "GET" },
|
|
params.timeoutMs,
|
|
probeSsrfPolicy,
|
|
);
|
|
if (!res.ok) {
|
|
return { ok: false, status: res.status, error: `HTTP ${res.status}` };
|
|
}
|
|
return { ok: true, status: res.status };
|
|
} catch (err) {
|
|
return {
|
|
ok: false,
|
|
status: null,
|
|
error: err instanceof Error ? err.message : String(err),
|
|
};
|
|
}
|
|
}
|