fix(extensions): route fetch calls through fetchWithSsrFGuard (#53929)

* 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>
This commit is contained in:
Jacob Tomlinson
2026-03-26 02:04:54 -07:00
committed by GitHub
parent dad68d319b
commit f92c92515b
33 changed files with 442 additions and 123 deletions

View File

@@ -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();

View File

@@ -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) {

View File

@@ -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})`;
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}`;
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;
if (opts.verbose) {
console.log(`[nextcloud-talk] Sent message ${messageId} to room ${roomToken}`);
}
} catch {
// Response parsing failed, but message was sent.
getNextcloudTalkRuntime().channel.activity.record({
channel: "nextcloud-talk",
accountId: account.accountId,
direction: "outbound",
});
return { messageId, roomToken, timestamp };
} finally {
await release();
}
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",
});
return { messageId, roomToken, timestamp };
}
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();
}
}

View File

@@ -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",

View File

@@ -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 = {