High severity7.4NVD Advisory· Published Apr 9, 2026· Updated Apr 15, 2026
CVE-2026-35629
CVE-2026-35629
Description
OpenClaw before 2026.3.25 contains a server-side request forgery vulnerability in multiple channel extensions that fail to properly guard configured base URLs against SSRF attacks. Attackers can exploit unprotected fetch() calls against configured endpoints to rebind requests to blocked internal destinations and access restricted resources.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
openclawnpm | < 2026.3.28 | 2026.3.28 |
Affected products
1Patches
1f92c92515bd4fix(extensions): route fetch calls through fetchWithSsrFGuard (#53929)
33 files changed · +442 −123
docs/.generated/config-baseline.json+40 −0 modified@@ -23296,6 +23296,16 @@ "tags": [], "hasChildren": false }, + { + "path": "channels.mattermost.accounts.*.allowPrivateNetwork", + "kind": "channel", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, { "path": "channels.mattermost.accounts.*.baseUrl", "kind": "channel", @@ -23823,6 +23833,16 @@ "tags": [], "hasChildren": false }, + { + "path": "channels.mattermost.allowPrivateNetwork", + "kind": "channel", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, { "path": "channels.mattermost.baseUrl", "kind": "channel", @@ -25406,6 +25426,16 @@ "tags": [], "hasChildren": false }, + { + "path": "channels.nextcloud-talk.accounts.*.allowPrivateNetwork", + "kind": "channel", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, { "path": "channels.nextcloud-talk.accounts.*.apiPassword", "kind": "channel", @@ -25994,6 +26024,16 @@ "tags": [], "hasChildren": false }, + { + "path": "channels.nextcloud-talk.allowPrivateNetwork", + "kind": "channel", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, { "path": "channels.nextcloud-talk.apiPassword", "kind": "channel",
docs/.generated/config-baseline.jsonl+5 −1 modified@@ -1,4 +1,4 @@ -{"generatedBy":"scripts/generate-config-doc-baseline.ts","recordType":"meta","totalPaths":5639} +{"generatedBy":"scripts/generate-config-doc-baseline.ts","recordType":"meta","totalPaths":5643} {"recordType":"path","path":"acp","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"ACP","help":"ACP runtime controls for enabling dispatch, selecting backends, constraining allowed agent targets, and tuning streamed turn projection behavior.","hasChildren":true} {"recordType":"path","path":"acp.allowedAgents","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"ACP Allowed Agents","help":"Allowlist of ACP target agent ids permitted for ACP runtime sessions. Empty means no additional allowlist restriction.","hasChildren":true} {"recordType":"path","path":"acp.allowedAgents.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} @@ -2091,6 +2091,7 @@ {"recordType":"path","path":"channels.mattermost.accounts.*.actions.reactions","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.mattermost.accounts.*.allowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.mattermost.accounts.*.allowFrom.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.mattermost.accounts.*.allowPrivateNetwork","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.mattermost.accounts.*.baseUrl","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.mattermost.accounts.*.blockStreaming","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.mattermost.accounts.*.blockStreamingCoalesce","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} @@ -2139,6 +2140,7 @@ {"recordType":"path","path":"channels.mattermost.actions.reactions","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.mattermost.allowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.mattermost.allowFrom.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.mattermost.allowPrivateNetwork","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.mattermost.baseUrl","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Mattermost Base URL","help":"Base URL for your Mattermost server (e.g., https://chat.example.com).","hasChildren":false} {"recordType":"path","path":"channels.mattermost.blockStreaming","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.mattermost.blockStreamingCoalesce","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} @@ -2284,6 +2286,7 @@ {"recordType":"path","path":"channels.nextcloud-talk.accounts.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.nextcloud-talk.accounts.*.allowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.nextcloud-talk.accounts.*.allowFrom.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.nextcloud-talk.accounts.*.allowPrivateNetwork","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.nextcloud-talk.accounts.*.apiPassword","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.nextcloud-talk.accounts.*.apiPassword.id","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.nextcloud-talk.accounts.*.apiPassword.provider","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} @@ -2340,6 +2343,7 @@ {"recordType":"path","path":"channels.nextcloud-talk.accounts.*.webhookPublicUrl","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.nextcloud-talk.allowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.nextcloud-talk.allowFrom.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.nextcloud-talk.allowPrivateNetwork","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.nextcloud-talk.apiPassword","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.nextcloud-talk.apiPassword.id","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.nextcloud-talk.apiPassword.provider","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
extensions/bluebubbles/src/actions.ts+6 −1 modified@@ -161,7 +161,12 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = { throw new Error(`BlueBubbles ${action} requires serverUrl and password.`); } - const resolved = await runtime.resolveChatGuidForTarget({ baseUrl, password, target }); + const resolved = await runtime.resolveChatGuidForTarget({ + baseUrl, + password, + target, + allowPrivateNetwork: account.config.allowPrivateNetwork === true, + }); if (!resolved) { throw new Error(`BlueBubbles ${action} failed: chatGuid not found for target.`); }
extensions/bluebubbles/src/attachments.ts+10 −1 modified@@ -16,8 +16,13 @@ import { buildBlueBubblesApiUrl, type BlueBubblesAttachment, type BlueBubblesSendTarget, + type SsrFPolicy, } from "./types.js"; +function blueBubblesPolicy(allowPrivateNetwork: boolean | undefined): SsrFPolicy { + return allowPrivateNetwork ? { allowPrivateNetwork: true } : {}; +} + export type BlueBubblesAttachmentOpts = { serverUrl?: string; password?: string; @@ -155,7 +160,7 @@ export async function sendBlueBubblesAttachment(params: { const fallbackName = wantsVoice ? "Audio Message" : "attachment"; filename = sanitizeFilename(filename, fallbackName); contentType = contentType?.trim() || undefined; - const { baseUrl, password, accountId } = resolveAccount(opts); + const { baseUrl, password, accountId, allowPrivateNetwork } = resolveAccount(opts); const privateApiStatus = getCachedBlueBubblesPrivateApiStatus(accountId); const privateApiEnabled = isBlueBubblesPrivateApiStatusEnabled(privateApiStatus); @@ -185,6 +190,7 @@ export async function sendBlueBubblesAttachment(params: { password, timeoutMs: opts.timeoutMs, target, + allowPrivateNetwork, }); if (!chatGuid) { // For handle targets (phone numbers/emails), auto-create a new DM chat @@ -194,6 +200,7 @@ export async function sendBlueBubblesAttachment(params: { password, address: target.address, timeoutMs: opts.timeoutMs, + allowPrivateNetwork, }); chatGuid = created.chatGuid; // If we still don't have a chatGuid, try resolving again (chat was created server-side) @@ -203,6 +210,7 @@ export async function sendBlueBubblesAttachment(params: { password, timeoutMs: opts.timeoutMs, target, + allowPrivateNetwork, }); } } @@ -281,6 +289,7 @@ export async function sendBlueBubblesAttachment(params: { boundary, parts, timeoutMs: opts.timeoutMs ?? 60_000, // longer timeout for file uploads + ssrfPolicy: blueBubblesPolicy(allowPrivateNetwork), }); await assertMultipartActionOk(res, "attachment send");
extensions/bluebubbles/src/channel.ts+1 −0 modified@@ -196,6 +196,7 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount, BlueBu baseUrl: account.baseUrl, password: account.config.password ?? null, timeoutMs, + allowPrivateNetwork: account.config.allowPrivateNetwork === true, }), resolveAccountSnapshot: ({ account, runtime, probe }) => { const running = runtime?.running ?? false;
extensions/bluebubbles/src/chat.ts+16 −4 modified@@ -1,11 +1,16 @@ import crypto from "node:crypto"; import path from "node:path"; +import type { SsrFPolicy } from "openclaw/plugin-sdk/infra-runtime"; import { resolveBlueBubblesServerAccount } from "./account-resolve.js"; import { assertMultipartActionOk, postMultipartFormData } 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; password?: string; @@ -41,7 +46,7 @@ async function sendBlueBubblesChatEndpointRequest(params: { if (!trimmed) { return; } - const { baseUrl, password, accountId } = resolveAccount(params.opts); + const { baseUrl, password, accountId, allowPrivateNetwork } = resolveAccount(params.opts); if (getCachedBlueBubblesPrivateApiStatus(accountId) === false) { return; } @@ -54,6 +59,7 @@ async function sendBlueBubblesChatEndpointRequest(params: { url, { method: params.method }, params.opts.timeoutMs, + blueBubblesPolicy(allowPrivateNetwork), ); await assertMultipartActionOk(res, params.action); } @@ -66,7 +72,7 @@ async function sendPrivateApiJsonRequest(params: { method: "POST" | "PUT" | "DELETE"; payload?: unknown; }): Promise<void> { - const { baseUrl, password, accountId } = resolveAccount(params.opts); + const { baseUrl, password, accountId, allowPrivateNetwork } = resolveAccount(params.opts); assertPrivateApiEnabled(accountId, params.feature); const url = buildBlueBubblesApiUrl({ baseUrl, @@ -80,7 +86,12 @@ async function sendPrivateApiJsonRequest(params: { request.body = JSON.stringify(params.payload); } - const res = await blueBubblesFetchWithTimeout(url, request, params.opts.timeoutMs); + const res = await blueBubblesFetchWithTimeout( + url, + request, + params.opts.timeoutMs, + blueBubblesPolicy(allowPrivateNetwork), + ); await assertMultipartActionOk(res, params.action); } @@ -282,7 +293,7 @@ export async function setGroupIconBlueBubbles( throw new Error("BlueBubbles setGroupIcon requires image buffer"); } - const { baseUrl, password, accountId } = resolveAccount(opts); + const { baseUrl, password, accountId, allowPrivateNetwork } = resolveAccount(opts); assertPrivateApiEnabled(accountId, "setGroupIcon"); const url = buildBlueBubblesApiUrl({ baseUrl, @@ -317,6 +328,7 @@ export async function setGroupIconBlueBubbles( boundary, parts, timeoutMs: opts.timeoutMs ?? 60_000, // longer timeout for file uploads + ssrfPolicy: blueBubblesPolicy(allowPrivateNetwork), }); await assertMultipartActionOk(res, "setGroupIcon");
extensions/bluebubbles/src/history.ts+4 −1 modified@@ -83,11 +83,13 @@ export async function fetchBlueBubblesHistory( let baseUrl: string; let password: string; + let allowPrivateNetwork = false; try { - ({ baseUrl, password } = resolveAccount(opts)); + ({ baseUrl, password, allowPrivateNetwork } = resolveAccount(opts)); } catch { return { entries: [], resolved: false }; } + const ssrfPolicy = allowPrivateNetwork ? { allowPrivateNetwork: true } : {}; // Try different common API patterns for fetching messages const possiblePaths = [ @@ -103,6 +105,7 @@ export async function fetchBlueBubblesHistory( url, { method: "GET" }, opts.timeoutMs ?? 10000, + ssrfPolicy, ); if (!res.ok) {
extensions/bluebubbles/src/monitor-processing.ts+1 −0 modified@@ -938,6 +938,7 @@ export async function processMessage( baseUrl, password, target: resolveTarget, + allowPrivateNetwork: account.config.allowPrivateNetwork === true, })) ?? undefined; } }
extensions/bluebubbles/src/monitor.ts+1 −0 modified@@ -275,6 +275,7 @@ export async function monitorBlueBubblesProvider( password: account.config.password, accountId: account.accountId, timeoutMs: 5000, + allowPrivateNetwork: account.config.allowPrivateNetwork === true, }).catch(() => null); if (serverInfo?.os_version) { runtime.log?.(`[${account.accountId}] BlueBubbles server macOS ${serverInfo.os_version}`);
extensions/bluebubbles/src/multipart.ts+3 −0 modified@@ -1,3 +1,4 @@ +import type { SsrFPolicy } from "openclaw/plugin-sdk/infra-runtime"; import { blueBubblesFetchWithTimeout } from "./types.js"; export function concatUint8Arrays(parts: Uint8Array[]): Uint8Array { @@ -16,6 +17,7 @@ export async function postMultipartFormData(params: { boundary: string; parts: Uint8Array[]; timeoutMs: number; + ssrfPolicy?: SsrFPolicy; }): Promise<Response> { const body = Buffer.from(concatUint8Arrays(params.parts)); return await blueBubblesFetchWithTimeout( @@ -28,6 +30,7 @@ export async function postMultipartFormData(params: { body, }, params.timeoutMs, + params.ssrfPolicy, ); }
extensions/bluebubbles/src/probe.ts+16 −2 modified@@ -35,6 +35,7 @@ export async function fetchBlueBubblesServerInfo(params: { password?: string | null; accountId?: string; timeoutMs?: number; + allowPrivateNetwork?: boolean; }): Promise<BlueBubblesServerInfo | null> { const baseUrl = normalizeSecretInputString(params.baseUrl); const password = normalizeSecretInputString(params.password); @@ -48,9 +49,15 @@ export async function fetchBlueBubblesServerInfo(params: { 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); + const res = await blueBubblesFetchWithTimeout( + url, + { method: "GET" }, + params.timeoutMs ?? 5000, + ssrfPolicy, + ); if (!res.ok) { return null; } @@ -138,6 +145,7 @@ 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); @@ -147,9 +155,15 @@ 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 }); try { - const res = await blueBubblesFetchWithTimeout(url, { method: "GET" }, params.timeoutMs); + const res = await blueBubblesFetchWithTimeout( + url, + { method: "GET" }, + params.timeoutMs, + probeSsrfPolicy, + ); if (!res.ok) { return { ok: false, status: res.status, error: `HTTP ${res.status}` }; }
extensions/bluebubbles/src/reactions.test.ts+10 −10 modified@@ -1,23 +1,23 @@ -import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { sendBlueBubblesReaction } from "./reactions.js"; +import { installBlueBubblesFetchTestHooks } from "./test-harness.js"; vi.mock("./accounts.js", async () => { const { createBlueBubblesAccountsMockModule } = await import("./test-harness.js"); return createBlueBubblesAccountsMockModule(); }); const mockFetch = vi.fn(); +const noopPrivateApiStatusMock = { + mockReturnValue: () => {}, +}; -describe("reactions", () => { - beforeEach(() => { - vi.stubGlobal("fetch", mockFetch); - mockFetch.mockReset(); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - }); +installBlueBubblesFetchTestHooks({ + mockFetch, + privateApiStatusMock: noopPrivateApiStatusMock, +}); +describe("reactions", () => { describe("sendBlueBubblesReaction", () => { async function expectRemovedReaction(emoji: string, expectedReaction = "-love") { mockFetch.mockResolvedValueOnce({
extensions/bluebubbles/src/reactions.ts+3 −1 modified@@ -149,7 +149,7 @@ export async function sendBlueBubblesReaction(params: { throw new Error("BlueBubbles reaction requires messageGuid."); } const reaction = normalizeBlueBubblesReactionInput(params.emoji, params.remove); - const { baseUrl, password, accountId } = resolveAccount(params.opts ?? {}); + const { baseUrl, password, accountId, allowPrivateNetwork } = resolveAccount(params.opts ?? {}); if (getCachedBlueBubblesPrivateApiStatus(accountId) === false) { throw new Error( "BlueBubbles reaction requires Private API, but it is disabled on the BlueBubbles server.", @@ -166,6 +166,7 @@ export async function sendBlueBubblesReaction(params: { reaction, partIndex: typeof params.partIndex === "number" ? params.partIndex : 0, }; + const ssrfPolicy = allowPrivateNetwork ? { allowPrivateNetwork: true } : {}; const res = await blueBubblesFetchWithTimeout( url, { @@ -174,6 +175,7 @@ export async function sendBlueBubblesReaction(params: { body: JSON.stringify(payload), }, params.opts?.timeoutMs, + ssrfPolicy, ); if (!res.ok) { const errorText = await res.text();
extensions/bluebubbles/src/send.ts+17 −0 modified@@ -14,8 +14,13 @@ import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl, type BlueBubblesSendTarget, + type SsrFPolicy, } from "./types.js"; +function blueBubblesPolicy(allowPrivateNetwork: boolean | undefined): SsrFPolicy { + return allowPrivateNetwork ? { allowPrivateNetwork: true } : {}; +} + export type BlueBubblesSendOpts = { serverUrl?: string; password?: string; @@ -194,6 +199,7 @@ async function queryChats(params: { timeoutMs?: number; offset: number; limit: number; + allowPrivateNetwork?: boolean; }): Promise<BlueBubblesChatRecord[]> { const url = buildBlueBubblesApiUrl({ baseUrl: params.baseUrl, @@ -212,6 +218,7 @@ async function queryChats(params: { }), }, params.timeoutMs, + blueBubblesPolicy(params.allowPrivateNetwork), ); if (!res.ok) { return []; @@ -226,6 +233,7 @@ export async function resolveChatGuidForTarget(params: { password: string; timeoutMs?: number; target: BlueBubblesSendTarget; + allowPrivateNetwork?: boolean; }): Promise<string | null> { if (params.target.kind === "chat_guid") { return params.target.chatGuid; @@ -246,6 +254,7 @@ export async function resolveChatGuidForTarget(params: { timeoutMs: params.timeoutMs, offset, limit, + allowPrivateNetwork: params.allowPrivateNetwork, }); if (chats.length === 0) { break; @@ -325,6 +334,7 @@ export async function createChatForHandle(params: { address: string; message?: string; timeoutMs?: number; + allowPrivateNetwork?: boolean; }): Promise<{ chatGuid: string | null; messageId: string }> { const url = buildBlueBubblesApiUrl({ baseUrl: params.baseUrl, @@ -344,6 +354,7 @@ export async function createChatForHandle(params: { body: JSON.stringify(payload), }, params.timeoutMs, + blueBubblesPolicy(params.allowPrivateNetwork), ); if (!res.ok) { const errorText = await res.text(); @@ -407,13 +418,15 @@ async function createNewChatWithMessage(params: { address: string; message: string; timeoutMs?: number; + allowPrivateNetwork?: boolean; }): Promise<BlueBubblesSendResult> { const result = await createChatForHandle({ baseUrl: params.baseUrl, password: params.password, address: params.address, message: params.message, timeoutMs: params.timeoutMs, + allowPrivateNetwork: params.allowPrivateNetwork, }); return { messageId: result.messageId }; } @@ -450,13 +463,15 @@ export async function sendMessageBlueBubbles( throw new Error("BlueBubbles password is required"); } const privateApiStatus = getCachedBlueBubblesPrivateApiStatus(account.accountId); + const allowPrivateNetwork = account.config.allowPrivateNetwork === true; const target = resolveBlueBubblesSendTarget(to); const chatGuid = await resolveChatGuidForTarget({ baseUrl, password, timeoutMs: opts.timeoutMs, target, + allowPrivateNetwork, }); if (!chatGuid) { // If target is a phone number/handle and no existing chat found, @@ -468,6 +483,7 @@ export async function sendMessageBlueBubbles( address: target.address, message: strippedText, timeoutMs: opts.timeoutMs, + allowPrivateNetwork, }); } throw new Error( @@ -523,6 +539,7 @@ export async function sendMessageBlueBubbles( body: JSON.stringify(payload), }, opts.timeoutMs, + blueBubblesPolicy(allowPrivateNetwork), ); if (!res.ok) { const errorText = await res.text();
extensions/bluebubbles/src/test-harness.ts+27 −0 modified@@ -1,5 +1,6 @@ import type { Mock } from "vitest"; import { afterEach, beforeEach, vi } from "vitest"; +import { _setFetchGuardForTesting } from "./types.js"; export const BLUE_BUBBLES_PRIVATE_API_STATUS = { enabled: true, @@ -69,13 +70,39 @@ export function installBlueBubblesFetchTestHooks(params: { }) { beforeEach(() => { vi.stubGlobal("fetch", params.mockFetch); + // Replace the SSRF guard with a passthrough that delegates to the mocked global.fetch, + // wrapping the result in a real Response so callers can call .arrayBuffer() on it. + _setFetchGuardForTesting(async (p) => { + const raw = await globalThis.fetch(p.url, p.init); + let body: ArrayBuffer; + if (typeof raw.arrayBuffer === "function") { + body = await raw.arrayBuffer(); + } else { + const text = + typeof (raw as { text?: () => Promise<string> }).text === "function" + ? await (raw as { text: () => Promise<string> }).text() + : typeof (raw as { json?: () => Promise<unknown> }).json === "function" + ? JSON.stringify(await (raw as { json: () => Promise<unknown> }).json()) + : ""; + body = new TextEncoder().encode(text).buffer; + } + return { + response: new Response(body, { + status: (raw as { status?: number }).status ?? 200, + headers: (raw as { headers?: HeadersInit }).headers, + }), + release: async () => {}, + finalUrl: p.url, + }; + }); params.mockFetch.mockReset(); params.privateApiStatusMock.mockReset?.(); params.privateApiStatusMock.mockClear?.(); params.privateApiStatusMock.mockReturnValue(BLUE_BUBBLES_PRIVATE_API_STATUS.unknown); }); afterEach(() => { + _setFetchGuardForTesting(null); vi.unstubAllGlobals(); }); }
extensions/bluebubbles/src/types.ts+35 −1 modified@@ -1,5 +1,7 @@ +import { fetchWithSsrFGuard, type SsrFPolicy } from "openclaw/plugin-sdk/infra-runtime"; import type { DmPolicy, GroupPolicy } from "openclaw/plugin-sdk/setup"; +export type { SsrFPolicy } from "openclaw/plugin-sdk/infra-runtime"; export type { DmPolicy, GroupPolicy } from "openclaw/plugin-sdk/setup"; export type BlueBubblesGroupConfig = { @@ -126,11 +128,43 @@ export function buildBlueBubblesApiUrl(params: { return url.toString(); } +// Overridable guard for testing; production code uses fetchWithSsrFGuard. +let _fetchGuard = fetchWithSsrFGuard; + +/** @internal Replace the SSRF fetch guard in tests. */ +export function _setFetchGuardForTesting(impl: typeof fetchWithSsrFGuard | null): void { + _fetchGuard = impl ?? fetchWithSsrFGuard; +} + export async function blueBubblesFetchWithTimeout( url: string, init: RequestInit, timeoutMs = DEFAULT_TIMEOUT_MS, -) { + ssrfPolicy?: SsrFPolicy, +): Promise<Response> { + if (ssrfPolicy !== undefined) { + // Use SSRF-guarded fetch; buffer the body so the dispatcher can be released + // before the caller reads the response (API responses are small JSON payloads). + const { response, release } = await _fetchGuard({ + url, + init, + timeoutMs, + policy: ssrfPolicy, + auditContext: "bluebubbles-api", + }); + // Null-body status codes per Fetch spec — Response constructor rejects a body for these. + const isNullBody = + response.status === 101 || + response.status === 204 || + response.status === 205 || + response.status === 304; + try { + const bodyBytes = isNullBody ? null : await response.arrayBuffer(); + return new Response(bodyBytes, { status: response.status, headers: response.headers }); + } finally { + await release(); + } + } const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try {
extensions/mattermost/src/channel.test.ts+10 −1 modified@@ -2,14 +2,23 @@ import { Type } from "@sinclair/typebox"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../runtime-api.js"; import { createChannelReplyPipeline } from "../runtime-api.js"; -const { sendMessageMattermostMock } = vi.hoisted(() => ({ +const { sendMessageMattermostMock, mockFetchGuard } = vi.hoisted(() => ({ sendMessageMattermostMock: vi.fn(), + mockFetchGuard: vi.fn(async (p: { url: string; init?: RequestInit }) => { + const response = await globalThis.fetch(p.url, p.init); + return { response, release: async () => {}, finalUrl: p.url }; + }), })); vi.mock("./mattermost/send.js", () => ({ sendMessageMattermost: sendMessageMattermostMock, })); +vi.mock("openclaw/plugin-sdk/infra-runtime", async (importOriginal) => { + const original = (await importOriginal()) as Record<string, unknown>; + return { ...original, fetchWithSsrFGuard: mockFetchGuard }; +}); + import { mattermostPlugin } from "./channel.js"; import { resetMattermostReactionBotUserCacheForTests } from "./mattermost/reactions.js"; import {
extensions/mattermost/src/config-schema.ts+2 −0 modified@@ -84,6 +84,8 @@ const MattermostAccountSchemaBase = z allowedSourceIps: z.array(z.string()).optional(), }) .optional(), + /** Allow fetching from private/internal IP addresses (e.g. localhost). Required for self-hosted Mattermost on LAN/VPN. */ + allowPrivateNetwork: z.boolean().optional(), /** Retry configuration for DM channel creation */ dmChannelRetry: DmChannelRetrySchema, })
extensions/mattermost/src/mattermost/client.ts+42 −3 modified@@ -1,8 +1,12 @@ +import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/infra-runtime"; + export type MattermostClient = { baseUrl: string; apiBaseUrl: string; token: string; request: <T>(path: string, init?: RequestInit) => Promise<T>; + /** Guarded fetch implementation; use in place of raw fetch for outbound requests. */ + fetchImpl: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>; }; export type MattermostUser = { @@ -74,14 +78,49 @@ export function createMattermostClient(params: { baseUrl: string; botToken: string; fetchImpl?: typeof fetch; + /** Allow requests to private/internal IPs (self-hosted/LAN deployments). */ + allowPrivateNetwork?: boolean; }): MattermostClient { const baseUrl = normalizeMattermostBaseUrl(params.baseUrl); if (!baseUrl) { throw new Error("Mattermost baseUrl is required"); } const apiBaseUrl = `${baseUrl}/api/v4`; const token = params.botToken.trim(); - const fetchImpl = params.fetchImpl ?? fetch; + // When no custom fetchImpl is provided (production path), use an SSRF-guarded wrapper + // that validates the target URL before making the request (DNS rebinding protection etc.). + // A custom fetchImpl is accepted for testing and special cases. + const externalFetchImpl = params.fetchImpl; + + // Guarded fetch adapter: calls fetchWithSsrFGuard and returns a plain Response. + // Body is buffered before releasing the dispatcher so callers get a complete Response. + // Null-body status codes per Fetch spec — Response constructor rejects a body for these. + const NULL_BODY_STATUSES = new Set([101, 204, 205, 304]); + + const guardedFetchImpl: typeof fetch = async (input, init) => { + const url = + typeof input === "string" + ? input + : input instanceof URL + ? input.toString() + : (input as Request).url; + const { response, release } = await fetchWithSsrFGuard({ + url, + init, + auditContext: "mattermost-api", + policy: params.allowPrivateNetwork ? { allowPrivateNetwork: true } : undefined, + }); + try { + const bodyBytes = NULL_BODY_STATUSES.has(response.status) + ? null + : await response.arrayBuffer(); + return new Response(bodyBytes, { status: response.status, headers: response.headers }); + } finally { + await release(); + } + }; + + const fetchImpl = externalFetchImpl ?? guardedFetchImpl; const request = async <T>(path: string, init?: RequestInit): Promise<T> => { const url = buildMattermostApiUrl(baseUrl, path); @@ -110,7 +149,7 @@ export function createMattermostClient(params: { return (await res.text()) as T; }; - return { baseUrl, apiBaseUrl, token, request }; + return { baseUrl, apiBaseUrl, token, request, fetchImpl }; } export async function fetchMattermostMe(client: MattermostClient): Promise<MattermostUser> { @@ -513,7 +552,7 @@ export async function uploadMattermostFile( form.append("files", blob, fileName); form.append("channel_id", params.channelId); - const res = await fetch(`${client.apiBaseUrl}/files`, { + const res = await client.fetchImpl(`${client.apiBaseUrl}/files`, { method: "POST", headers: { Authorization: `Bearer ${client.token}`,
extensions/mattermost/src/mattermost/directory.ts+5 −1 modified@@ -24,7 +24,11 @@ function buildClient(params: { if (!account.enabled || !account.botToken || !account.baseUrl) { return null; } - return createMattermostClient({ baseUrl: account.baseUrl, botToken: account.botToken }); + return createMattermostClient({ + baseUrl: account.baseUrl, + botToken: account.botToken, + allowPrivateNetwork: account.config?.allowPrivateNetwork === true, + }); } /**
extensions/mattermost/src/mattermost/monitor.ts+5 −1 modified@@ -263,7 +263,11 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} ); } - const client = createMattermostClient({ baseUrl, botToken }); + const client = createMattermostClient({ + baseUrl, + botToken, + allowPrivateNetwork: account.config?.allowPrivateNetwork === true, + }); const botUser = await fetchMattermostMe(client); const botUserId = botUser.id; const botUsername = botUser.username?.trim() || undefined;
extensions/mattermost/src/mattermost/reactions.ts+1 −0 modified@@ -81,6 +81,7 @@ async function runMattermostReaction( baseUrl, botToken, fetchImpl: params.fetchImpl, + allowPrivateNetwork: resolved.config?.allowPrivateNetwork === true, }); const cacheKey = `${baseUrl}:${botToken}`;
extensions/mattermost/src/mattermost/send.ts+30 −10 modified@@ -128,13 +128,17 @@ export function parseMattermostTarget(raw: string): MattermostTarget { return { kind: "channel", id: trimmed }; } -async function resolveBotUser(baseUrl: string, token: string): Promise<MattermostUser> { +async function resolveBotUser( + baseUrl: string, + token: string, + allowPrivateNetwork?: boolean, +): Promise<MattermostUser> { const key = cacheKey(baseUrl, token); const cached = botUserCache.get(key); if (cached) { return cached; } - const client = createMattermostClient({ baseUrl, botToken: token }); + const client = createMattermostClient({ baseUrl, botToken: token, allowPrivateNetwork }); const user = await fetchMattermostMe(client); botUserCache.set(key, user); return user; @@ -144,14 +148,19 @@ async function resolveUserIdByUsername(params: { baseUrl: string; token: string; username: string; + allowPrivateNetwork?: boolean; }): Promise<string> { const { baseUrl, token, username } = params; const key = `${cacheKey(baseUrl, token)}::${username.toLowerCase()}`; const cached = userByNameCache.get(key); if (cached?.id) { return cached.id; } - const client = createMattermostClient({ baseUrl, botToken: token }); + const client = createMattermostClient({ + baseUrl, + botToken: token, + allowPrivateNetwork: params.allowPrivateNetwork, + }); const user = await fetchMattermostUserByUsername(client, username); userByNameCache.set(key, user); return user.id; @@ -161,14 +170,19 @@ async function resolveChannelIdByName(params: { baseUrl: string; token: string; name: string; + allowPrivateNetwork?: boolean; }): Promise<string> { const { baseUrl, token, name } = params; const key = `${cacheKey(baseUrl, token)}::channel::${name.toLowerCase()}`; const cached = channelByNameCache.get(key); if (cached) { return cached; } - const client = createMattermostClient({ baseUrl, botToken: token }); + const client = createMattermostClient({ + baseUrl, + botToken: token, + allowPrivateNetwork: params.allowPrivateNetwork, + }); const me = await fetchMattermostMe(client); const teams = await fetchMattermostUserTeams(client, me.id); for (const team of teams) { @@ -189,6 +203,7 @@ type ResolveTargetChannelIdParams = { target: MattermostTarget; baseUrl: string; token: string; + allowPrivateNetwork?: boolean; dmRetryOptions?: CreateDmChannelRetryOptions; logger?: { debug?: (msg: string) => void; warn?: (msg: string) => void }; }; @@ -227,6 +242,7 @@ async function resolveTargetChannelId(params: ResolveTargetChannelIdParams): Pro baseUrl: params.baseUrl, token: params.token, name: params.target.name, + allowPrivateNetwork: params.allowPrivateNetwork, }); } const userId = params.target.id @@ -235,16 +251,18 @@ async function resolveTargetChannelId(params: ResolveTargetChannelIdParams): Pro baseUrl: params.baseUrl, token: params.token, username: params.target.username ?? "", + allowPrivateNetwork: params.allowPrivateNetwork, }); const dmKey = `${cacheKey(params.baseUrl, params.token)}::dm::${userId}`; const cachedDm = dmChannelCache.get(dmKey); if (cachedDm) { return cachedDm; } - const botUser = await resolveBotUser(params.baseUrl, params.token); + const botUser = await resolveBotUser(params.baseUrl, params.token, params.allowPrivateNetwork); const client = createMattermostClient({ baseUrl: params.baseUrl, botToken: params.token, + allowPrivateNetwork: params.allowPrivateNetwork, }); const channel = await createMattermostDirectChannelWithRetry(client, [botUser.id, userId], { @@ -270,6 +288,7 @@ type MattermostSendContext = { token: string; baseUrl: string; channelId: string; + allowPrivateNetwork?: boolean; }; async function resolveMattermostSendContext( @@ -319,10 +338,12 @@ async function resolveMattermostSendContext( : undefined; const dmRetryOptions = mergeDmRetryOptions(accountRetryConfig, opts.dmRetryOptions); + const allowPrivateNetwork = account.config.allowPrivateNetwork === true; const channelId = await resolveTargetChannelId({ target, baseUrl, token, + allowPrivateNetwork, dmRetryOptions, logger: core.logging.shouldLogVerbose() ? logger : undefined, }); @@ -333,6 +354,7 @@ async function resolveMattermostSendContext( token, baseUrl, channelId, + allowPrivateNetwork, }; } @@ -350,12 +372,10 @@ export async function sendMessageMattermost( ): Promise<MattermostSendResult> { const core = getCore(); const logger = core.logging.getChildLogger({ module: "mattermost" }); - const { cfg, accountId, token, baseUrl, channelId } = await resolveMattermostSendContext( - to, - opts, - ); + const { cfg, accountId, token, baseUrl, channelId, allowPrivateNetwork } = + await resolveMattermostSendContext(to, opts); - const client = createMattermostClient({ baseUrl, botToken: token }); + const client = createMattermostClient({ baseUrl, botToken: token, allowPrivateNetwork }); let props = opts.props; if (!props && Array.isArray(opts.buttons) && opts.buttons.length > 0) { setInteractionSecret(accountId, token);
extensions/mattermost/src/mattermost/slash-http.ts+1 −0 modified@@ -260,6 +260,7 @@ export function createSlashCommandHttpHandler(params: SlashHttpHandlerParams) { const client = createMattermostClient({ baseUrl: account.baseUrl ?? "", botToken: account.botToken ?? "", + allowPrivateNetwork: account.config?.allowPrivateNetwork === true, }); const auth = await authorizeSlashInvocation({
extensions/mattermost/src/mattermost/target-resolution.ts+5 −1 modified@@ -79,7 +79,11 @@ export async function resolveMattermostOpaqueTarget(params: { return { kind: "channel", id: input, to: `channel:${input}` }; } - const client = createMattermostClient({ baseUrl, botToken: token }); + const client = createMattermostClient({ + baseUrl, + botToken: token, + allowPrivateNetwork: account?.config?.allowPrivateNetwork === true, + }); try { await fetchMattermostUser(client, input); mattermostOpaqueTargetCache.set(key, true);
extensions/mattermost/src/types.ts+2 −0 modified@@ -86,6 +86,8 @@ export type MattermostAccountConfig = { */ allowedSourceIps?: string[]; }; + /** Allow fetching from private/internal IP addresses (e.g. localhost). Required for self-hosted Mattermost on LAN/VPN. */ + allowPrivateNetwork?: boolean; /** Retry configuration for DM channel creation */ dmChannelRetry?: { /** Maximum number of retry attempts (default: 3) */
extensions/nextcloud-talk/src/config-schema.ts+2 −0 modified@@ -43,6 +43,8 @@ export const NextcloudTalkAccountSchemaBase = z groupAllowFrom: z.array(z.string()).optional(), groupPolicy: GroupPolicySchema.optional().default("allowlist"), rooms: z.record(z.string(), NextcloudTalkRoomSchema.optional()).optional(), + /** Allow fetching from private/internal IP addresses (e.g. localhost). Required for self-hosted Nextcloud on LAN/VPN. */ + allowPrivateNetwork: z.boolean().optional(), ...ReplyRuntimeConfigSchemaShape, }) .strict();
extensions/nextcloud-talk/src/room-info.ts+1 −0 modified@@ -105,6 +105,7 @@ export async function resolveNextcloudTalkRoomKind(params: { }, }, auditContext: "nextcloud-talk.room-info", + policy: account.config?.allowPrivateNetwork ? { allowPrivateNetwork: true } : undefined, }); try { if (!response.ok) {
extensions/nextcloud-talk/src/send.ts+84 −65 modified@@ -1,3 +1,4 @@ +import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/infra-runtime"; import { resolveNextcloudTalkAccount } from "./accounts.js"; import { stripNextcloudTalkTargetPrefix } from "./normalize.js"; import { getNextcloudTalkRuntime } from "./runtime.js"; @@ -101,69 +102,78 @@ export async function sendMessageNextcloudTalk( const url = `${baseUrl}/ocs/v2.php/apps/spreed/api/v1/bot/${roomToken}/message`; - const response = await fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/json", - "OCS-APIRequest": "true", - "X-Nextcloud-Talk-Bot-Random": random, - "X-Nextcloud-Talk-Bot-Signature": signature, + const { response, release } = await fetchWithSsrFGuard({ + url, + init: { + method: "POST", + headers: { + "Content-Type": "application/json", + "OCS-APIRequest": "true", + "X-Nextcloud-Talk-Bot-Random": random, + "X-Nextcloud-Talk-Bot-Signature": signature, + }, + body: bodyStr, }, - body: bodyStr, + auditContext: "nextcloud-talk-send", + policy: account.config?.allowPrivateNetwork ? { allowPrivateNetwork: true } : undefined, }); - if (!response.ok) { - const errorBody = await response.text().catch(() => ""); - const status = response.status; - let errorMsg = `Nextcloud Talk send failed (${status})`; - - if (status === 400) { - errorMsg = `Nextcloud Talk: bad request - ${errorBody || "invalid message format"}`; - } else if (status === 401) { - errorMsg = "Nextcloud Talk: authentication failed - check bot secret"; - } else if (status === 403) { - errorMsg = "Nextcloud Talk: forbidden - bot may not have permission in this room"; - } else if (status === 404) { - errorMsg = `Nextcloud Talk: room not found (token=${roomToken})`; - } else if (errorBody) { - errorMsg = `Nextcloud Talk send failed: ${errorBody}`; + try { + if (!response.ok) { + const errorBody = await response.text().catch(() => ""); + const status = response.status; + let errorMsg = `Nextcloud Talk send failed (${status})`; + + if (status === 400) { + errorMsg = `Nextcloud Talk: bad request - ${errorBody || "invalid message format"}`; + } else if (status === 401) { + errorMsg = "Nextcloud Talk: authentication failed - check bot secret"; + } else if (status === 403) { + errorMsg = "Nextcloud Talk: forbidden - bot may not have permission in this room"; + } else if (status === 404) { + errorMsg = `Nextcloud Talk: room not found (token=${roomToken})`; + } else if (errorBody) { + errorMsg = `Nextcloud Talk send failed: ${errorBody}`; + } + + throw new Error(errorMsg); } - throw new Error(errorMsg); - } - - let messageId = "unknown"; - let timestamp: number | undefined; - try { - const data = (await response.json()) as { - ocs?: { - data?: { - id?: number | string; - timestamp?: number; + let messageId = "unknown"; + let timestamp: number | undefined; + try { + const data = (await response.json()) as { + ocs?: { + data?: { + id?: number | string; + timestamp?: number; + }; }; }; - }; - if (data.ocs?.data?.id != null) { - messageId = String(data.ocs.data.id); + if (data.ocs?.data?.id != null) { + messageId = String(data.ocs.data.id); + } + if (typeof data.ocs?.data?.timestamp === "number") { + timestamp = data.ocs.data.timestamp; + } + } catch { + // Response parsing failed, but message was sent. } - if (typeof data.ocs?.data?.timestamp === "number") { - timestamp = data.ocs.data.timestamp; - } - } catch { - // Response parsing failed, but message was sent. - } - if (opts.verbose) { - console.log(`[nextcloud-talk] Sent message ${messageId} to room ${roomToken}`); - } + if (opts.verbose) { + console.log(`[nextcloud-talk] Sent message ${messageId} to room ${roomToken}`); + } - getNextcloudTalkRuntime().channel.activity.record({ - channel: "nextcloud-talk", - accountId: account.accountId, - direction: "outbound", - }); + getNextcloudTalkRuntime().channel.activity.record({ + channel: "nextcloud-talk", + accountId: account.accountId, + direction: "outbound", + }); - return { messageId, roomToken, timestamp }; + return { messageId, roomToken, timestamp }; + } finally { + await release(); + } } export async function sendReactionNextcloudTalk( @@ -184,21 +194,30 @@ export async function sendReactionNextcloudTalk( const url = `${baseUrl}/ocs/v2.php/apps/spreed/api/v1/bot/${normalizedToken}/reaction/${messageId}`; - const response = await fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/json", - "OCS-APIRequest": "true", - "X-Nextcloud-Talk-Bot-Random": random, - "X-Nextcloud-Talk-Bot-Signature": signature, + const { response, release } = await fetchWithSsrFGuard({ + url, + init: { + method: "POST", + headers: { + "Content-Type": "application/json", + "OCS-APIRequest": "true", + "X-Nextcloud-Talk-Bot-Random": random, + "X-Nextcloud-Talk-Bot-Signature": signature, + }, + body, }, - body, + auditContext: "nextcloud-talk-reaction", + policy: account.config?.allowPrivateNetwork ? { allowPrivateNetwork: true } : undefined, }); - if (!response.ok) { - const errorBody = await response.text().catch(() => ""); - throw new Error(`Nextcloud Talk reaction failed: ${response.status} ${errorBody}`.trim()); - } + try { + if (!response.ok) { + const errorBody = await response.text().catch(() => ""); + throw new Error(`Nextcloud Talk reaction failed: ${response.status} ${errorBody}`.trim()); + } - return { ok: true }; + return { ok: true }; + } finally { + await release(); + } }
extensions/nextcloud-talk/src/setup.test.ts+20 −3 modified@@ -2,7 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/routing"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createSendCfgThreadingRuntime, expectProvidedCfgSkipsRuntimeLoad, @@ -38,6 +38,7 @@ const hoisted = vi.hoisted(() => ({ random: "r", signature: "s", })), + mockFetchGuard: vi.fn(), })); vi.mock("./monitor.js", async () => { @@ -68,6 +69,14 @@ vi.mock("./signature.js", async (importOriginal) => { }; }); +vi.mock("openclaw/plugin-sdk/infra-runtime", async (importOriginal) => { + const original = (await importOriginal()) as Record<string, unknown>; + return { + ...original, + fetchWithSsrFGuard: hoisted.mockFetchGuard, + }; +}); + const accountsActual = await vi.importActual<typeof import("./accounts.js")>("./accounts.js"); hoisted.resolveNextcloudTalkAccount.mockImplementation(accountsActual.resolveNextcloudTalkAccount); @@ -415,14 +424,23 @@ describe("resolveNextcloudTalkAccount", () => { describe("nextcloud-talk send cfg threading", () => { const fetchMock = vi.fn<typeof fetch>(); + beforeEach(() => { + vi.stubGlobal("fetch", fetchMock); + // Wire the SSRF guard mock to delegate to the global fetch mock + hoisted.mockFetchGuard.mockImplementation(async (p: { url: string; init?: RequestInit }) => { + const response = await globalThis.fetch(p.url, p.init); + return { response, release: async () => {}, finalUrl: p.url }; + }); + }); + afterEach(() => { fetchMock.mockReset(); + hoisted.mockFetchGuard.mockReset(); vi.unstubAllGlobals(); }); it("uses provided cfg for sendMessage and skips runtime loadConfig", async () => { const cfg = { source: "provided" } as const; - vi.stubGlobal("fetch", fetchMock); hoisted.resolveNextcloudTalkAccount.mockReturnValue({ accountId: "default", baseUrl: "https://nextcloud.example.com", @@ -459,7 +477,6 @@ describe("nextcloud-talk send cfg threading", () => { it("falls back to runtime cfg for sendReaction when cfg is omitted", async () => { const runtimeCfg = { source: "runtime" } as const; hoisted.loadConfig.mockReturnValueOnce(runtimeCfg); - vi.stubGlobal("fetch", fetchMock); hoisted.resolveNextcloudTalkAccount.mockReturnValue({ accountId: "default", baseUrl: "https://nextcloud.example.com",
extensions/nextcloud-talk/src/types.ts+2 −0 modified@@ -75,6 +75,8 @@ export type NextcloudTalkAccountConfig = { responsePrefix?: string; /** Media upload max size in MB. */ mediaMaxMb?: number; + /** Allow fetching from private/internal IP addresses (e.g. localhost). Required for self-hosted Nextcloud on LAN/VPN. */ + allowPrivateNetwork?: boolean; }; export type NextcloudTalkConfig = {
extensions/thread-ownership/index.ts+33 −16 modified@@ -1,4 +1,10 @@ -import { definePluginEntry, type OpenClawConfig, type OpenClawPluginApi } from "./api.js"; +import { + definePluginEntry, + fetchWithSsrFGuard, + ssrfPolicyFromAllowPrivateNetwork, + type OpenClawConfig, + type OpenClawPluginApi, +} from "./api.js"; type ThreadOwnershipConfig = { forwarderUrl?: string; @@ -89,24 +95,35 @@ export default definePluginEntry({ if (mentionedThreads.has(`${channelId}:${threadTs}`)) return; try { - const resp = await fetch(`${forwarderUrl}/api/v1/ownership/${channelId}/${threadTs}`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ agent_id: agentId }), - signal: AbortSignal.timeout(3000), + // The forwarder is an internal service (e.g. a Docker container); allow private-network + // access but pin DNS so DNS-rebinding attacks cannot pivot to a different internal host. + const { response: resp, release } = await fetchWithSsrFGuard({ + url: `${forwarderUrl}/api/v1/ownership/${channelId}/${threadTs}`, + init: { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ agent_id: agentId }), + }, + timeoutMs: 3000, + policy: ssrfPolicyFromAllowPrivateNetwork(true), + auditContext: "thread-ownership", }); - if (resp.ok) { - return; + try { + if (resp.ok) { + return; + } + if (resp.status === 409) { + const body = (await resp.json()) as { owner?: string }; + api.logger.info?.( + `thread-ownership: cancelled send to ${channelId}:${threadTs} — owned by ${body.owner}`, + ); + return { cancel: true }; + } + api.logger.warn?.(`thread-ownership: unexpected status ${resp.status}, allowing send`); + } finally { + await release(); } - if (resp.status === 409) { - const body = (await resp.json()) as { owner?: string }; - api.logger.info?.( - `thread-ownership: cancelled send to ${channelId}:${threadTs} — owned by ${body.owner}`, - ); - return { cancel: true }; - } - api.logger.warn?.(`thread-ownership: unexpected status ${resp.status}, allowing send`); } catch (err) { api.logger.warn?.( `thread-ownership: ownership check failed (${String(err)}), allowing send`,
src/plugin-sdk/thread-ownership.ts+2 −0 modified@@ -4,3 +4,5 @@ export { definePluginEntry } from "./plugin-entry.js"; export type { OpenClawConfig } from "../config/config.js"; export type { OpenClawPluginApi } from "../plugins/types.js"; +export { fetchWithSsrFGuard } from "../infra/net/fetch-guard.js"; +export { ssrfPolicyFromAllowPrivateNetwork } from "./ssrf-policy.js";
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
6- github.com/openclaw/openclaw/commit/f92c92515bd439a71bd03eb1bc969c1964f17acfnvdPatchWEB
- github.com/advisories/GHSA-pg2v-8xwh-qhccghsaADVISORY
- github.com/advisories/GHSA-rhfg-j8jq-7v2hghsaADVISORY
- github.com/openclaw/openclaw/security/advisories/GHSA-rhfg-j8jq-7v2hnvdVendor AdvisoryWEB
- nvd.nist.gov/vuln/detail/CVE-2026-35629ghsaADVISORY
- www.vulncheck.com/advisories/openclaw-server-side-request-forgery-via-unguarded-configured-base-urls-in-channel-extensionsnvdThird Party AdvisoryWEB
News mentions
0No linked articles in our index yet.