diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index fa13133106e..b03912d8e38 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -1,4 +1,4 @@ -4c880eb1ce03486f47aa21f49317ad15fc8d92bb720d70205743b72e45cf5fa3 config-baseline.json -03ff4a3e314f17dd8851aed3653269294bc62412bee05a6804dce840bd3d7551 config-baseline.core.json -73b57f395a2ad983f1660112d0b2b998342f1ddbe3089b440d7f73d0665de739 config-baseline.channel.json +20a882f9991e17310013471756ac7ec62c272e29490daeede9c0901bd51c0e69 config-baseline.json +8ba6e5c959d5fc3eee9e6c5d1d8f764f164052f4207c0352bb39e2a7dbad64a8 config-baseline.core.json +ca6d1fa8a3507566979ea2da2b88a6a7ae49d650f3ebd3eee14a22ed18e5be89 config-baseline.channel.json 17fd37605bf6cb087932ec2ebcfa9dd22e669fa6b8b93081ab2deac9d24821c5 config-baseline.plugin.json diff --git a/extensions/bluebubbles/contract-surfaces.ts b/extensions/bluebubbles/contract-surfaces.ts index bc8f64f050f..274710269c3 100644 --- a/extensions/bluebubbles/contract-surfaces.ts +++ b/extensions/bluebubbles/contract-surfaces.ts @@ -1,3 +1,4 @@ +export { normalizeCompatibilityConfig, legacyConfigRules } from "./src/doctor-contract.js"; export { collectRuntimeConfigAssignments, secretTargetRegistryEntries, diff --git a/extensions/bluebubbles/src/account-resolve.ts b/extensions/bluebubbles/src/account-resolve.ts index daf1e1b6f81..43d304571cc 100644 --- a/extensions/bluebubbles/src/account-resolve.ts +++ b/extensions/bluebubbles/src/account-resolve.ts @@ -1,4 +1,7 @@ -import { isBlockedHostnameOrIp } from "openclaw/plugin-sdk/ssrf-runtime"; +import { + isBlockedHostnameOrIp, + isPrivateNetworkOptInEnabled, +} from "openclaw/plugin-sdk/ssrf-runtime"; import { resolveBlueBubblesAccount } from "./accounts.js"; import type { OpenClawConfig } from "./runtime-api.js"; import { normalizeResolvedSecretInputString } from "./secret-input.js"; @@ -58,6 +61,6 @@ export function resolveBlueBubblesServerAccount(params: BlueBubblesAccountResolv baseUrl, password, accountId: account.accountId, - allowPrivateNetwork: account.config.allowPrivateNetwork === true || autoAllowPrivateNetwork, + allowPrivateNetwork: isPrivateNetworkOptInEnabled(account.config) || autoAllowPrivateNetwork, }; } diff --git a/extensions/bluebubbles/src/actions.ts b/extensions/bluebubbles/src/actions.ts index c96dd526d56..f09322d4aa4 100644 --- a/extensions/bluebubbles/src/actions.ts +++ b/extensions/bluebubbles/src/actions.ts @@ -7,6 +7,7 @@ import { readStringParam, } from "openclaw/plugin-sdk/channel-actions"; import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime"; +import { isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime"; import { extractToolSend } from "openclaw/plugin-sdk/tool-send"; import { resolveBlueBubblesAccount } from "./accounts.js"; import { @@ -173,7 +174,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = { baseUrl, password, target, - allowPrivateNetwork: account.config.allowPrivateNetwork === true, + allowPrivateNetwork: isPrivateNetworkOptInEnabled(account.config), }); if (!resolved) { throw new Error(`BlueBubbles ${action} failed: chatGuid not found for target.`); diff --git a/extensions/bluebubbles/src/attachments.test.ts b/extensions/bluebubbles/src/attachments.test.ts index c8b4bd39648..0ea5b198b31 100644 --- a/extensions/bluebubbles/src/attachments.test.ts +++ b/extensions/bluebubbles/src/attachments.test.ts @@ -278,7 +278,9 @@ describe("downloadBlueBubblesAttachment", () => { bluebubbles: { serverUrl: "http://localhost:1234", password: "test", - allowPrivateNetwork: true, + network: { + dangerouslyAllowPrivateNetwork: true, + }, }, }, }, diff --git a/extensions/bluebubbles/src/channel.ts b/extensions/bluebubbles/src/channel.ts index 58a82e439f8..88f7469776d 100644 --- a/extensions/bluebubbles/src/channel.ts +++ b/extensions/bluebubbles/src/channel.ts @@ -17,6 +17,7 @@ import { createComputedAccountStatusAdapter, createDefaultChannelRuntimeState, } from "openclaw/plugin-sdk/status-helpers"; +import { isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime"; import { listBlueBubblesAccountIds, type ResolvedBlueBubblesAccount, @@ -34,6 +35,7 @@ import { } from "./channel-shared.js"; import type { BlueBubblesProbe } from "./channel.runtime.js"; import { createBlueBubblesConversationBindingManager } from "./conversation-bindings.js"; +import { bluebubblesDoctor } from "./doctor.js"; import { matchBlueBubblesAcpConversation, normalizeBlueBubblesAcpConversationId, @@ -100,6 +102,7 @@ export const bluebubblesPlugin: ChannelPlugin account.configured, describeAccount: (account): ChannelAccountSnapshot => describeBlueBubblesAccount(account), }, + doctor: bluebubblesDoctor, conversationBindings: { supportsCurrentConversationBinding: true, createManager: ({ cfg, accountId }) => @@ -226,7 +229,7 @@ export const bluebubblesPlugin: ChannelPlugin { const running = runtime?.running ?? false; diff --git a/extensions/bluebubbles/src/config-schema.ts b/extensions/bluebubbles/src/config-schema.ts index 0ab030a17a2..78df708ed63 100644 --- a/extensions/bluebubbles/src/config-schema.ts +++ b/extensions/bluebubbles/src/config-schema.ts @@ -32,6 +32,14 @@ const bluebubblesGroupConfigSchema = z.object({ tools: ToolPolicySchema, }); +const bluebubblesNetworkSchema = z + .object({ + /** Dangerous opt-in for same-host or trusted private/internal BlueBubbles deployments. */ + dangerouslyAllowPrivateNetwork: z.boolean().optional(), + }) + .strict() + .optional(); + const bluebubblesAccountSchema = z .object({ name: z.string().optional(), @@ -53,7 +61,7 @@ const bluebubblesAccountSchema = z mediaMaxMb: z.number().int().positive().optional(), mediaLocalRoots: z.array(z.string()).optional(), sendReadReceipts: z.boolean().optional(), - allowPrivateNetwork: z.boolean().optional(), + network: bluebubblesNetworkSchema, blockStreaming: z.boolean().optional(), groups: z.object({}).catchall(bluebubblesGroupConfigSchema).optional(), }) diff --git a/extensions/bluebubbles/src/doctor-contract.ts b/extensions/bluebubbles/src/doctor-contract.ts new file mode 100644 index 00000000000..5069371a141 --- /dev/null +++ b/extensions/bluebubbles/src/doctor-contract.ts @@ -0,0 +1,103 @@ +import type { + ChannelDoctorConfigMutation, + ChannelDoctorLegacyConfigRule, +} from "openclaw/plugin-sdk/channel-contract"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { + hasLegacyFlatAllowPrivateNetworkAlias, + migrateLegacyFlatAllowPrivateNetworkAlias, +} from "openclaw/plugin-sdk/ssrf-runtime"; + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function hasLegacyAllowPrivateNetworkInAccounts(value: unknown): boolean { + const accounts = isRecord(value) ? value : null; + return Boolean( + accounts && + Object.values(accounts).some((account) => + hasLegacyFlatAllowPrivateNetworkAlias(isRecord(account) ? account : {}), + ), + ); +} + +export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] = [ + { + path: ["channels", "bluebubbles"], + message: + "channels.bluebubbles.allowPrivateNetwork is legacy; use channels.bluebubbles.network.dangerouslyAllowPrivateNetwork instead (auto-migrated on load).", + match: (value) => hasLegacyFlatAllowPrivateNetworkAlias(isRecord(value) ? value : {}), + }, + { + path: ["channels", "bluebubbles", "accounts"], + message: + "channels.bluebubbles.accounts..allowPrivateNetwork is legacy; use channels.bluebubbles.accounts..network.dangerouslyAllowPrivateNetwork instead (auto-migrated on load).", + match: hasLegacyAllowPrivateNetworkInAccounts, + }, +]; + +export function normalizeCompatibilityConfig({ + cfg, +}: { + cfg: OpenClawConfig; +}): ChannelDoctorConfigMutation { + const channels = isRecord(cfg.channels) ? cfg.channels : null; + const bluebubbles = isRecord(channels?.bluebubbles) ? channels.bluebubbles : null; + if (!bluebubbles) { + return { config: cfg, changes: [] }; + } + + const changes: string[] = []; + let updatedBluebubbles = bluebubbles; + let changed = false; + + const topLevel = migrateLegacyFlatAllowPrivateNetworkAlias({ + entry: updatedBluebubbles, + pathPrefix: "channels.bluebubbles", + changes, + }); + updatedBluebubbles = topLevel.entry; + changed = changed || topLevel.changed; + + const accounts = isRecord(updatedBluebubbles.accounts) ? updatedBluebubbles.accounts : null; + if (accounts) { + let accountsChanged = false; + const nextAccounts: Record = { ...accounts }; + for (const [accountId, accountValue] of Object.entries(accounts)) { + const account = isRecord(accountValue) ? accountValue : null; + if (!account) { + continue; + } + const migrated = migrateLegacyFlatAllowPrivateNetworkAlias({ + entry: account, + pathPrefix: `channels.bluebubbles.accounts.${accountId}`, + changes, + }); + if (!migrated.changed) { + continue; + } + nextAccounts[accountId] = migrated.entry; + accountsChanged = true; + } + if (accountsChanged) { + updatedBluebubbles = { ...updatedBluebubbles, accounts: nextAccounts }; + changed = true; + } + } + + if (!changed) { + return { config: cfg, changes: [] }; + } + + return { + config: { + ...cfg, + channels: { + ...cfg.channels, + bluebubbles: updatedBluebubbles as NonNullable["bluebubbles"], + }, + }, + changes, + }; +} diff --git a/extensions/bluebubbles/src/doctor.test.ts b/extensions/bluebubbles/src/doctor.test.ts new file mode 100644 index 00000000000..428c18fe9c9 --- /dev/null +++ b/extensions/bluebubbles/src/doctor.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { bluebubblesDoctor } from "./doctor.js"; + +describe("bluebubbles doctor", () => { + it("normalizes legacy private-network aliases", () => { + const normalize = bluebubblesDoctor.normalizeCompatibilityConfig; + expect(normalize).toBeDefined(); + if (!normalize) { + return; + } + + const result = normalize({ + cfg: { + channels: { + bluebubbles: { + allowPrivateNetwork: true, + accounts: { + default: { + allowPrivateNetwork: false, + }, + }, + }, + }, + } as never, + }); + + expect(result.config.channels?.bluebubbles?.network).toEqual({ + dangerouslyAllowPrivateNetwork: true, + }); + expect(result.config.channels?.bluebubbles?.accounts?.default?.network).toEqual({ + dangerouslyAllowPrivateNetwork: false, + }); + }); +}); diff --git a/extensions/bluebubbles/src/doctor.ts b/extensions/bluebubbles/src/doctor.ts new file mode 100644 index 00000000000..b428454b24e --- /dev/null +++ b/extensions/bluebubbles/src/doctor.ts @@ -0,0 +1,105 @@ +import type { + ChannelDoctorAdapter, + ChannelDoctorConfigMutation, + ChannelDoctorLegacyConfigRule, +} from "openclaw/plugin-sdk/channel-contract"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { + hasLegacyFlatAllowPrivateNetworkAlias, + migrateLegacyFlatAllowPrivateNetworkAlias, +} from "openclaw/plugin-sdk/ssrf-runtime"; + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function hasLegacyAllowPrivateNetworkInAccounts(value: unknown): boolean { + const accounts = isRecord(value) ? value : null; + return Boolean( + accounts && + Object.values(accounts).some((account) => + hasLegacyFlatAllowPrivateNetworkAlias(isRecord(account) ? account : {}), + ), + ); +} + +function normalizeBlueBubblesCompatibilityConfig(cfg: OpenClawConfig): ChannelDoctorConfigMutation { + const channels = isRecord(cfg.channels) ? cfg.channels : null; + const bluebubbles = isRecord(channels?.bluebubbles) ? channels.bluebubbles : null; + if (!bluebubbles) { + return { config: cfg, changes: [] }; + } + + const changes: string[] = []; + let updatedBluebubbles = bluebubbles; + let changed = false; + + const topLevel = migrateLegacyFlatAllowPrivateNetworkAlias({ + entry: updatedBluebubbles, + pathPrefix: "channels.bluebubbles", + changes, + }); + updatedBluebubbles = topLevel.entry; + changed = changed || topLevel.changed; + + const accounts = isRecord(updatedBluebubbles.accounts) ? updatedBluebubbles.accounts : null; + if (accounts) { + let accountsChanged = false; + const nextAccounts: Record = { ...accounts }; + for (const [accountId, accountValue] of Object.entries(accounts)) { + const account = isRecord(accountValue) ? accountValue : null; + if (!account) { + continue; + } + const migrated = migrateLegacyFlatAllowPrivateNetworkAlias({ + entry: account, + pathPrefix: `channels.bluebubbles.accounts.${accountId}`, + changes, + }); + if (!migrated.changed) { + continue; + } + nextAccounts[accountId] = migrated.entry; + accountsChanged = true; + } + if (accountsChanged) { + updatedBluebubbles = { ...updatedBluebubbles, accounts: nextAccounts }; + changed = true; + } + } + + if (!changed) { + return { config: cfg, changes: [] }; + } + + return { + config: { + ...cfg, + channels: { + ...cfg.channels, + bluebubbles: updatedBluebubbles as NonNullable["bluebubbles"], + }, + }, + changes, + }; +} + +const BLUEBUBBLES_LEGACY_CONFIG_RULES: ChannelDoctorLegacyConfigRule[] = [ + { + path: ["channels", "bluebubbles"], + message: + "channels.bluebubbles.allowPrivateNetwork is legacy; use channels.bluebubbles.network.dangerouslyAllowPrivateNetwork instead (auto-migrated on load).", + match: (value) => hasLegacyFlatAllowPrivateNetworkAlias(isRecord(value) ? value : {}), + }, + { + path: ["channels", "bluebubbles", "accounts"], + message: + "channels.bluebubbles.accounts..allowPrivateNetwork is legacy; use channels.bluebubbles.accounts..network.dangerouslyAllowPrivateNetwork instead (auto-migrated on load).", + match: hasLegacyAllowPrivateNetworkInAccounts, + }, +]; + +export const bluebubblesDoctor: ChannelDoctorAdapter = { + legacyConfigRules: BLUEBUBBLES_LEGACY_CONFIG_RULES, + normalizeCompatibilityConfig: ({ cfg }) => normalizeBlueBubblesCompatibilityConfig(cfg), +}; diff --git a/extensions/bluebubbles/src/monitor-processing.ts b/extensions/bluebubbles/src/monitor-processing.ts index 0b9a463ea45..2f99d5eb618 100644 --- a/extensions/bluebubbles/src/monitor-processing.ts +++ b/extensions/bluebubbles/src/monitor-processing.ts @@ -3,6 +3,7 @@ import { resolveTextChunksWithFallback, sendMediaWithLeadingCaption, } from "openclaw/plugin-sdk/reply-payload"; +import { isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime"; import { downloadBlueBubblesAttachment } from "./attachments.js"; import { markBlueBubblesChatRead, sendBlueBubblesTyping } from "./chat.js"; import { resolveBlueBubblesConversationRoute } from "./conversation-route.js"; @@ -934,7 +935,7 @@ export async function processMessage( chatGuid: message.chatGuid, chatId: message.chatId, chatIdentifier: message.chatIdentifier, - allowPrivateNetwork: account.config.allowPrivateNetwork === true, + allowPrivateNetwork: isPrivateNetworkOptInEnabled(account.config), }); if (fetchedParticipants?.length) { message.participants = fetchedParticipants; @@ -1147,7 +1148,7 @@ export async function processMessage( baseUrl, password, target: resolveTarget, - allowPrivateNetwork: account.config.allowPrivateNetwork === true, + allowPrivateNetwork: isPrivateNetworkOptInEnabled(account.config), })) ?? undefined; } } diff --git a/extensions/bluebubbles/src/monitor.ts b/extensions/bluebubbles/src/monitor.ts index e95a00bb1a5..61105098df8 100644 --- a/extensions/bluebubbles/src/monitor.ts +++ b/extensions/bluebubbles/src/monitor.ts @@ -1,5 +1,6 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import { safeEqualSecret } from "openclaw/plugin-sdk/browser-support"; +import { isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime"; import { createBlueBubblesDebounceRegistry } from "./monitor-debounce.js"; import { normalizeWebhookMessage, normalizeWebhookReaction } from "./monitor-normalize.js"; import { logVerbose, processMessage, processReaction } from "./monitor-processing.js"; @@ -327,7 +328,7 @@ export async function monitorBlueBubblesProvider( password: account.config.password, accountId: account.accountId, timeoutMs: 5000, - allowPrivateNetwork: account.config.allowPrivateNetwork === true, + allowPrivateNetwork: isPrivateNetworkOptInEnabled(account.config), }).catch(() => null); if (serverInfo?.os_version) { runtime.log?.(`[${account.accountId}] BlueBubbles server macOS ${serverInfo.os_version}`); diff --git a/extensions/bluebubbles/src/types.ts b/extensions/bluebubbles/src/types.ts index 3b496ae74c8..90a4dbafc0a 100644 --- a/extensions/bluebubbles/src/types.ts +++ b/extensions/bluebubbles/src/types.ts @@ -25,6 +25,11 @@ export type BlueBubblesActionConfig = { sendAttachment?: boolean; }; +export type BlueBubblesNetworkConfig = { + /** Dangerous opt-in for same-host or trusted private/internal BlueBubbles deployments. */ + dangerouslyAllowPrivateNetwork?: boolean; +}; + export type BlueBubblesAccountConfig = { /** Optional display name for this account (used in CLI/UI lists). */ name?: string; @@ -71,8 +76,8 @@ export type BlueBubblesAccountConfig = { mediaLocalRoots?: string[]; /** Send read receipts for incoming messages (default: true). */ sendReadReceipts?: boolean; - /** Allow fetching from private/internal IP addresses (e.g. localhost). Required for same-host BlueBubbles setups. */ - allowPrivateNetwork?: boolean; + /** Network policy overrides for same-host or trusted private/internal BlueBubbles deployments. */ + network?: BlueBubblesNetworkConfig; /** Per-group configuration keyed by chat GUID or identifier. */ groups?: Record; /** Per-action tool gating (default: true for all). */ diff --git a/extensions/matrix/contract-api.ts b/extensions/matrix/contract-api.ts index ac88b533d0e..c7df30d9b2f 100644 --- a/extensions/matrix/contract-api.ts +++ b/extensions/matrix/contract-api.ts @@ -3,6 +3,7 @@ export { resetMatrixThreadBindingsForTests, } from "./src/matrix/thread-bindings.js"; export { setMatrixRuntime } from "./src/runtime.js"; +export { normalizeCompatibilityConfig, legacyConfigRules } from "./src/doctor-contract.js"; export { namedAccountPromotionKeys, resolveSingleAccountPromotionTarget, diff --git a/extensions/matrix/contract-surfaces.ts b/extensions/matrix/contract-surfaces.ts index ad6efb034ad..d91337332b5 100644 --- a/extensions/matrix/contract-surfaces.ts +++ b/extensions/matrix/contract-surfaces.ts @@ -1,3 +1,4 @@ +export { normalizeCompatibilityConfig, legacyConfigRules } from "./src/doctor-contract.js"; export { namedAccountPromotionKeys, resolveSingleAccountPromotionTarget, diff --git a/extensions/matrix/src/channel.setup.test.ts b/extensions/matrix/src/channel.setup.test.ts index 379077c69fb..4e1e97e9daf 100644 --- a/extensions/matrix/src/channel.setup.test.ts +++ b/extensions/matrix/src/channel.setup.test.ts @@ -254,7 +254,9 @@ describe("matrix setup post-write bootstrap", () => { channels: { matrix: { homeserver: "http://localhost.localdomain:8008", - allowPrivateNetwork: true, + network: { + dangerouslyAllowPrivateNetwork: true, + }, proxy: "http://127.0.0.1:7890", accounts: { ops: { diff --git a/extensions/matrix/src/channel.ts b/extensions/matrix/src/channel.ts index f5c3af310d4..afdb4e4ca74 100644 --- a/extensions/matrix/src/channel.ts +++ b/extensions/matrix/src/channel.ts @@ -157,7 +157,7 @@ const matrixConfigAdapter = createScopedChannelConfigAdapter< clearBaseFields: [ "name", "homeserver", - "allowPrivateNetwork", + "network", "proxy", "userId", "accessToken", diff --git a/extensions/matrix/src/config-schema.ts b/extensions/matrix/src/config-schema.ts index da43e5b3d2a..716e68f764f 100644 --- a/extensions/matrix/src/config-schema.ts +++ b/extensions/matrix/src/config-schema.ts @@ -55,6 +55,13 @@ const matrixRoomSchema = z }) .optional(); +const matrixNetworkSchema = z + .object({ + dangerouslyAllowPrivateNetwork: z.boolean().optional(), + }) + .strict() + .optional(); + export const MatrixConfigSchema = z.object({ name: z.string().optional(), enabled: z.boolean().optional(), @@ -62,7 +69,7 @@ export const MatrixConfigSchema = z.object({ accounts: z.record(z.string(), z.unknown()).optional(), markdown: MarkdownConfigSchema, homeserver: z.string().optional(), - allowPrivateNetwork: z.boolean().optional(), + network: matrixNetworkSchema, proxy: z.string().optional(), userId: z.string().optional(), accessToken: buildSecretInputSchema().optional(), diff --git a/extensions/matrix/src/doctor-contract.ts b/extensions/matrix/src/doctor-contract.ts new file mode 100644 index 00000000000..83b2021cd52 --- /dev/null +++ b/extensions/matrix/src/doctor-contract.ts @@ -0,0 +1,213 @@ +import type { + ChannelDoctorConfigMutation, + ChannelDoctorLegacyConfigRule, +} from "openclaw/plugin-sdk/channel-contract"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { + hasLegacyFlatAllowPrivateNetworkAlias, + migrateLegacyFlatAllowPrivateNetworkAlias, +} from "openclaw/plugin-sdk/ssrf-runtime"; + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function hasLegacyMatrixRoomAllowAlias(value: unknown): boolean { + const room = isRecord(value) ? value : null; + return Boolean(room && typeof room.allow === "boolean"); +} + +function hasLegacyMatrixRoomMapAllowAliases(value: unknown): boolean { + const rooms = isRecord(value) ? value : null; + return Boolean(rooms && Object.values(rooms).some((room) => hasLegacyMatrixRoomAllowAlias(room))); +} + +function hasLegacyMatrixAccountRoomAllowAliases(value: unknown): boolean { + const accounts = isRecord(value) ? value : null; + if (!accounts) { + return false; + } + return Object.values(accounts).some((account) => { + if (!isRecord(account)) { + return false; + } + return ( + hasLegacyMatrixRoomMapAllowAliases(account.groups) || + hasLegacyMatrixRoomMapAllowAliases(account.rooms) + ); + }); +} + +function hasLegacyMatrixAccountPrivateNetworkAliases(value: unknown): boolean { + const accounts = isRecord(value) ? value : null; + if (!accounts) { + return false; + } + return Object.values(accounts).some((account) => + hasLegacyFlatAllowPrivateNetworkAlias(isRecord(account) ? account : {}), + ); +} + +function normalizeMatrixRoomAllowAliases(params: { + rooms: Record; + pathPrefix: string; + changes: string[]; +}): { rooms: Record; changed: boolean } { + let changed = false; + const nextRooms: Record = { ...params.rooms }; + for (const [roomId, roomValue] of Object.entries(params.rooms)) { + const room = isRecord(roomValue) ? roomValue : null; + if (!room || typeof room.allow !== "boolean") { + continue; + } + const nextRoom = { ...room }; + if (typeof nextRoom.enabled !== "boolean") { + nextRoom.enabled = room.allow; + } + delete nextRoom.allow; + nextRooms[roomId] = nextRoom; + changed = true; + params.changes.push( + `Moved ${params.pathPrefix}.${roomId}.allow → ${params.pathPrefix}.${roomId}.enabled (${String(nextRoom.enabled)}).`, + ); + } + return { rooms: nextRooms, changed }; +} + +export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] = [ + { + path: ["channels", "matrix"], + message: + "channels.matrix.allowPrivateNetwork is legacy; use channels.matrix.network.dangerouslyAllowPrivateNetwork instead (auto-migrated on load).", + match: (value) => hasLegacyFlatAllowPrivateNetworkAlias(isRecord(value) ? value : {}), + }, + { + path: ["channels", "matrix", "accounts"], + message: + "channels.matrix.accounts..allowPrivateNetwork is legacy; use channels.matrix.accounts..network.dangerouslyAllowPrivateNetwork instead (auto-migrated on load).", + match: hasLegacyMatrixAccountPrivateNetworkAliases, + }, + { + path: ["channels", "matrix", "groups"], + message: + "channels.matrix.groups..allow is legacy; use channels.matrix.groups..enabled instead (auto-migrated on load).", + match: hasLegacyMatrixRoomMapAllowAliases, + }, + { + path: ["channels", "matrix", "rooms"], + message: + "channels.matrix.rooms..allow is legacy; use channels.matrix.rooms..enabled instead (auto-migrated on load).", + match: hasLegacyMatrixRoomMapAllowAliases, + }, + { + path: ["channels", "matrix", "accounts"], + message: + "channels.matrix.accounts..{groups,rooms}..allow is legacy; use channels.matrix.accounts..{groups,rooms}..enabled instead (auto-migrated on load).", + match: hasLegacyMatrixAccountRoomAllowAliases, + }, +]; + +export function normalizeCompatibilityConfig({ + cfg, +}: { + cfg: OpenClawConfig; +}): ChannelDoctorConfigMutation { + const channels = isRecord(cfg.channels) ? cfg.channels : null; + const matrix = isRecord(channels?.matrix) ? channels.matrix : null; + if (!matrix) { + return { config: cfg, changes: [] }; + } + + const changes: string[] = []; + let updatedMatrix: Record = matrix; + let changed = false; + + const topLevelPrivateNetwork = migrateLegacyFlatAllowPrivateNetworkAlias({ + entry: updatedMatrix, + pathPrefix: "channels.matrix", + changes, + }); + updatedMatrix = topLevelPrivateNetwork.entry; + changed = changed || topLevelPrivateNetwork.changed; + + const normalizeTopLevelRoomScope = (key: "groups" | "rooms") => { + const rooms = isRecord(updatedMatrix[key]) ? updatedMatrix[key] : null; + if (!rooms) { + return; + } + const normalized = normalizeMatrixRoomAllowAliases({ + rooms, + pathPrefix: `channels.matrix.${key}`, + changes, + }); + if (normalized.changed) { + updatedMatrix = { ...updatedMatrix, [key]: normalized.rooms }; + changed = true; + } + }; + + normalizeTopLevelRoomScope("groups"); + normalizeTopLevelRoomScope("rooms"); + + const accounts = isRecord(updatedMatrix.accounts) ? updatedMatrix.accounts : null; + if (accounts) { + let accountsChanged = false; + const nextAccounts: Record = { ...accounts }; + for (const [accountId, accountValue] of Object.entries(accounts)) { + const account = isRecord(accountValue) ? accountValue : null; + if (!account) { + continue; + } + let nextAccount: Record = account; + let accountChanged = false; + + const privateNetworkMigration = migrateLegacyFlatAllowPrivateNetworkAlias({ + entry: nextAccount, + pathPrefix: `channels.matrix.accounts.${accountId}`, + changes, + }); + if (privateNetworkMigration.changed) { + nextAccount = privateNetworkMigration.entry; + accountChanged = true; + } + + for (const key of ["groups", "rooms"] as const) { + const rooms = isRecord(nextAccount[key]) ? nextAccount[key] : null; + if (!rooms) { + continue; + } + const normalized = normalizeMatrixRoomAllowAliases({ + rooms, + pathPrefix: `channels.matrix.accounts.${accountId}.${key}`, + changes, + }); + if (normalized.changed) { + nextAccount = { ...nextAccount, [key]: normalized.rooms }; + accountChanged = true; + } + } + if (accountChanged) { + nextAccounts[accountId] = nextAccount; + accountsChanged = true; + } + } + if (accountsChanged) { + updatedMatrix = { ...updatedMatrix, accounts: nextAccounts }; + changed = true; + } + } + + if (!changed) { + return { config: cfg, changes: [] }; + } + return { + config: { + ...cfg, + channels: { + ...(cfg.channels ?? {}), + matrix: updatedMatrix as NonNullable["matrix"], + }, + }, + changes, + }; +} diff --git a/extensions/matrix/src/doctor.test.ts b/extensions/matrix/src/doctor.test.ts index b6b57f41b9f..bbc01514907 100644 --- a/extensions/matrix/src/doctor.test.ts +++ b/extensions/matrix/src/doctor.test.ts @@ -170,4 +170,40 @@ describe("matrix doctor", () => { ]), ); }); + + it("normalizes legacy Matrix private-network aliases", () => { + const normalize = matrixDoctor.normalizeCompatibilityConfig; + expect(normalize).toBeDefined(); + if (!normalize) { + return; + } + + const result = normalize({ + cfg: { + channels: { + matrix: { + allowPrivateNetwork: true, + accounts: { + work: { + allowPrivateNetwork: false, + }, + }, + }, + }, + } as never, + }); + + expect(result.config.channels?.matrix?.network).toEqual({ + dangerouslyAllowPrivateNetwork: true, + }); + expect(result.config.channels?.matrix?.accounts?.work?.network).toEqual({ + dangerouslyAllowPrivateNetwork: false, + }); + expect(result.changes).toEqual( + expect.arrayContaining([ + "Moved channels.matrix.allowPrivateNetwork → channels.matrix.network.dangerouslyAllowPrivateNetwork (true).", + "Moved channels.matrix.accounts.work.allowPrivateNetwork → channels.matrix.accounts.work.network.dangerouslyAllowPrivateNetwork (false).", + ]), + ); + }); }); diff --git a/extensions/matrix/src/doctor.ts b/extensions/matrix/src/doctor.ts index 8978d4c4e35..94a79bc76d7 100644 --- a/extensions/matrix/src/doctor.ts +++ b/extensions/matrix/src/doctor.ts @@ -9,6 +9,11 @@ import { formatPluginInstallPathIssue, removePluginFromConfig, } from "openclaw/plugin-sdk/runtime"; +import { + hasLegacyFlatAllowPrivateNetworkAlias, + isPrivateNetworkOptInEnabled, + migrateLegacyFlatAllowPrivateNetworkAlias, +} from "openclaw/plugin-sdk/ssrf-runtime"; import { autoMigrateLegacyMatrixState, autoPrepareLegacyMatrixCrypto, @@ -49,6 +54,16 @@ function hasLegacyMatrixAccountRoomAllowAliases(value: unknown): boolean { }); } +function hasLegacyMatrixAccountPrivateNetworkAliases(value: unknown): boolean { + const accounts = isRecord(value) ? value : null; + if (!accounts) { + return false; + } + return Object.values(accounts).some((account) => + hasLegacyFlatAllowPrivateNetworkAlias(isRecord(account) ? account : {}), + ); +} + function normalizeMatrixRoomAllowAliases(params: { rooms: Record; pathPrefix: string; @@ -86,6 +101,14 @@ function normalizeMatrixCompatibilityConfig(cfg: OpenClawConfig): ChannelDoctorC let updatedMatrix: Record = matrix; let changed = false; + const topLevelPrivateNetwork = migrateLegacyFlatAllowPrivateNetworkAlias({ + entry: updatedMatrix, + pathPrefix: "channels.matrix", + changes, + }); + updatedMatrix = topLevelPrivateNetwork.entry; + changed = changed || topLevelPrivateNetwork.changed; + const normalizeTopLevelRoomScope = (key: "groups" | "rooms") => { const rooms = isRecord(updatedMatrix[key]) ? updatedMatrix[key] : null; if (!rooms) { @@ -116,6 +139,17 @@ function normalizeMatrixCompatibilityConfig(cfg: OpenClawConfig): ChannelDoctorC } let nextAccount: Record = account; let accountChanged = false; + + const privateNetworkMigration = migrateLegacyFlatAllowPrivateNetworkAlias({ + entry: nextAccount, + pathPrefix: `channels.matrix.accounts.${accountId}`, + changes, + }); + if (privateNetworkMigration.changed) { + nextAccount = privateNetworkMigration.entry; + accountChanged = true; + } + for (const key of ["groups", "rooms"] as const) { const rooms = isRecord(nextAccount[key]) ? nextAccount[key] : null; if (!rooms) { @@ -158,6 +192,18 @@ function normalizeMatrixCompatibilityConfig(cfg: OpenClawConfig): ChannelDoctorC } const MATRIX_LEGACY_CONFIG_RULES: ChannelDoctorLegacyConfigRule[] = [ + { + path: ["channels", "matrix"], + message: + "channels.matrix.allowPrivateNetwork is legacy; use channels.matrix.network.dangerouslyAllowPrivateNetwork instead (auto-migrated on load).", + match: (value) => hasLegacyFlatAllowPrivateNetworkAlias(isRecord(value) ? value : {}), + }, + { + path: ["channels", "matrix", "accounts"], + message: + "channels.matrix.accounts..allowPrivateNetwork is legacy; use channels.matrix.accounts..network.dangerouslyAllowPrivateNetwork instead (auto-migrated on load).", + match: hasLegacyMatrixAccountPrivateNetworkAliases, + }, { path: ["channels", "matrix", "groups"], message: diff --git a/extensions/matrix/src/matrix/client/config-runtime-api.ts b/extensions/matrix/src/matrix/client/config-runtime-api.ts index e9448aa5d97..44ac0bf7e80 100644 --- a/extensions/matrix/src/matrix/client/config-runtime-api.ts +++ b/extensions/matrix/src/matrix/client/config-runtime-api.ts @@ -6,6 +6,7 @@ export { export { isPrivateOrLoopbackHost } from "./private-network-host.js"; export { assertHttpUrlTargetsPrivateNetwork, + isPrivateNetworkOptInEnabled, ssrfPolicyFromAllowPrivateNetwork, type LookupFn, type SsrFPolicy, diff --git a/extensions/matrix/src/matrix/client/config.ts b/extensions/matrix/src/matrix/client/config.ts index ee6f44e4605..45fd57b2c57 100644 --- a/extensions/matrix/src/matrix/client/config.ts +++ b/extensions/matrix/src/matrix/client/config.ts @@ -19,6 +19,7 @@ import { DEFAULT_ACCOUNT_ID, assertHttpUrlTargetsPrivateNetwork, isPrivateOrLoopbackHost, + isPrivateNetworkOptInEnabled, type LookupFn, normalizeAccountId, normalizeOptionalAccountId, @@ -545,7 +546,7 @@ export function resolveMatrixConfig( }); const initialSyncLimit = clampMatrixInitialSyncLimit(matrix.initialSyncLimit); const encryption = matrix.encryption ?? false; - const allowPrivateNetwork = matrix.allowPrivateNetwork === true ? true : undefined; + const allowPrivateNetwork = isPrivateNetworkOptInEnabled(matrix) ? true : undefined; return { homeserver: resolvedStrings.homeserver, userId: resolvedStrings.userId, @@ -614,7 +615,9 @@ export function resolveMatrixConfigForAccount( const encryption = typeof account.encryption === "boolean" ? account.encryption : (matrix.encryption ?? false); const allowPrivateNetwork = - account.allowPrivateNetwork === true || matrix.allowPrivateNetwork === true ? true : undefined; + isPrivateNetworkOptInEnabled(account) || isPrivateNetworkOptInEnabled(matrix) + ? true + : undefined; return { homeserver: resolvedStrings.homeserver, diff --git a/extensions/matrix/src/matrix/config-update.test.ts b/extensions/matrix/src/matrix/config-update.test.ts index b89c5d9fd55..463c2e7a46b 100644 --- a/extensions/matrix/src/matrix/config-update.test.ts +++ b/extensions/matrix/src/matrix/config-update.test.ts @@ -80,7 +80,9 @@ describe("updateMatrixAccountConfig", () => { accounts: { default: { allowBots: true, - allowPrivateNetwork: true, + network: { + dangerouslyAllowPrivateNetwork: true, + }, proxy: "http://127.0.0.1:7890", }, }, @@ -97,7 +99,7 @@ describe("updateMatrixAccountConfig", () => { expect(updated.channels?.["matrix"]?.accounts?.default).toMatchObject({ allowBots: "mentions", }); - expect(updated.channels?.["matrix"]?.accounts?.default?.allowPrivateNetwork).toBeUndefined(); + expect(updated.channels?.["matrix"]?.accounts?.default?.network).toBeUndefined(); expect(updated.channels?.["matrix"]?.accounts?.default?.proxy).toBeUndefined(); }); diff --git a/extensions/matrix/src/matrix/config-update.ts b/extensions/matrix/src/matrix/config-update.ts index 79f278b606a..482522f5805 100644 --- a/extensions/matrix/src/matrix/config-update.ts +++ b/extensions/matrix/src/matrix/config-update.ts @@ -167,10 +167,19 @@ export function updateMatrixAccountConfig( applyNullableStringField(nextAccount, "avatarUrl", patch.avatarUrl); if (patch.allowPrivateNetwork !== undefined) { + const nextNetwork = + nextAccount.network && typeof nextAccount.network === "object" + ? { ...(nextAccount.network as Record) } + : {}; if (patch.allowPrivateNetwork === null) { - delete nextAccount.allowPrivateNetwork; + delete nextNetwork.dangerouslyAllowPrivateNetwork; } else { - nextAccount.allowPrivateNetwork = patch.allowPrivateNetwork; + nextNetwork.dangerouslyAllowPrivateNetwork = patch.allowPrivateNetwork; + } + if (Object.keys(nextNetwork).length > 0) { + nextAccount.network = nextNetwork; + } else { + delete nextAccount.network; } } diff --git a/extensions/matrix/src/matrix/resolved-config.ts b/extensions/matrix/src/matrix/resolved-config.ts index e998adf8c7f..97629d0e575 100644 --- a/extensions/matrix/src/matrix/resolved-config.ts +++ b/extensions/matrix/src/matrix/resolved-config.ts @@ -2,7 +2,10 @@ import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/acco import type { PinnedDispatcherPolicy } from "openclaw/plugin-sdk/infra-runtime"; import { coerceSecretRef } from "openclaw/plugin-sdk/provider-auth"; import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-input"; -import { ssrfPolicyFromAllowPrivateNetwork } from "openclaw/plugin-sdk/ssrf-runtime"; +import { + isPrivateNetworkOptInEnabled, + ssrfPolicyFromAllowPrivateNetwork, +} from "openclaw/plugin-sdk/ssrf-runtime"; import { resolveMatrixAccountStringValues } from "../auth-precedence.js"; import { getMatrixScopedEnvVarNames } from "../env-vars.js"; import type { CoreConfig } from "../types.js"; @@ -273,8 +276,9 @@ export function resolveMatrixConfigForAccount( accountInitialSyncLimit ?? clampMatrixInitialSyncLimit(matrix.initialSyncLimit); const encryption = typeof account.encryption === "boolean" ? account.encryption : (matrix.encryption ?? false); - const allowPrivateNetwork = - account.allowPrivateNetwork === true || matrix.allowPrivateNetwork === true ? true : undefined; + const allowPrivateNetwork = isPrivateNetworkOptInEnabled(account) || isPrivateNetworkOptInEnabled(matrix) + ? true + : undefined; return { homeserver: resolvedStrings.homeserver, diff --git a/extensions/matrix/src/onboarding.test.ts b/extensions/matrix/src/onboarding.test.ts index 3cefb9e689e..95f92b021e2 100644 --- a/extensions/matrix/src/onboarding.test.ts +++ b/extensions/matrix/src/onboarding.test.ts @@ -187,7 +187,9 @@ describe("matrix onboarding", () => { expect(result.cfg.channels?.matrix).toMatchObject({ homeserver: "http://localhost.localdomain:8008", - allowPrivateNetwork: true, + network: { + dangerouslyAllowPrivateNetwork: true, + }, accessToken: "ops-token", }); }); diff --git a/extensions/matrix/src/onboarding.ts b/extensions/matrix/src/onboarding.ts index 211fa313f75..2165b2a20be 100644 --- a/extensions/matrix/src/onboarding.ts +++ b/extensions/matrix/src/onboarding.ts @@ -1,4 +1,5 @@ import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id"; +import { isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime"; import { type ChannelSetupDmPolicy, type ChannelSetupWizardAdapter, @@ -324,17 +325,15 @@ async function runMatrixConfigure(params: { ).trim(); const requiresAllowPrivateNetwork = requiresMatrixPrivateNetworkOptIn(homeserver); const shouldPromptAllowPrivateNetwork = - requiresAllowPrivateNetwork || existing.allowPrivateNetwork === true; + requiresAllowPrivateNetwork || isPrivateNetworkOptInEnabled(existing); const allowPrivateNetwork = shouldPromptAllowPrivateNetwork ? await params.prompter.confirm({ message: "Allow private/internal Matrix homeserver traffic for this account?", - initialValue: existing.allowPrivateNetwork === true || requiresAllowPrivateNetwork, + initialValue: isPrivateNetworkOptInEnabled(existing) || requiresAllowPrivateNetwork, }) : false; if (requiresAllowPrivateNetwork && !allowPrivateNetwork) { - throw new Error( - "Matrix homeserver requires allowPrivateNetwork for trusted private/internal access", - ); + throw new Error("Matrix homeserver requires explicit private-network opt-in"); } await resolveValidatedMatrixHomeserverUrl(homeserver, { allowPrivateNetwork, diff --git a/extensions/matrix/src/types.ts b/extensions/matrix/src/types.ts index 4924bb895bc..91e6818f8f6 100644 --- a/extensions/matrix/src/types.ts +++ b/extensions/matrix/src/types.ts @@ -77,6 +77,11 @@ export type MatrixExecApprovalConfig = { target?: MatrixExecApprovalTarget; }; +export type MatrixNetworkConfig = { + /** Dangerous opt-in for trusted private/internal Matrix homeservers. */ + dangerouslyAllowPrivateNetwork?: boolean; +}; + /** Per-account Matrix config (excludes the accounts field to prevent recursion). */ export type MatrixAccountConfig = Omit; @@ -91,8 +96,8 @@ export type MatrixConfig = { defaultAccount?: string; /** Matrix homeserver URL (https://matrix.example.org). */ homeserver?: string; - /** Allow Matrix homeserver traffic to private/internal hosts. */ - allowPrivateNetwork?: boolean; + /** Network policy overrides for trusted private/internal Matrix homeservers. */ + network?: MatrixNetworkConfig; /** Optional HTTP(S) proxy URL for Matrix connections (e.g. http://127.0.0.1:7890). */ proxy?: string; /** Matrix user id (@user:server). */ diff --git a/extensions/mattermost/contract-api.ts b/extensions/mattermost/contract-api.ts index bc8f64f050f..274710269c3 100644 --- a/extensions/mattermost/contract-api.ts +++ b/extensions/mattermost/contract-api.ts @@ -1,3 +1,4 @@ +export { normalizeCompatibilityConfig, legacyConfigRules } from "./src/doctor-contract.js"; export { collectRuntimeConfigAssignments, secretTargetRegistryEntries, diff --git a/extensions/mattermost/contract-surfaces.ts b/extensions/mattermost/contract-surfaces.ts index 5b44a1ce276..059e43be554 100644 --- a/extensions/mattermost/contract-surfaces.ts +++ b/extensions/mattermost/contract-surfaces.ts @@ -1,3 +1,4 @@ +export { normalizeCompatibilityConfig, legacyConfigRules } from "./src/doctor-contract.js"; export { collectRuntimeConfigAssignments, secretTargetRegistryEntries, diff --git a/extensions/mattermost/src/channel.ts b/extensions/mattermost/src/channel.ts index 8b79fc9e4e0..825010dd179 100644 --- a/extensions/mattermost/src/channel.ts +++ b/extensions/mattermost/src/channel.ts @@ -20,6 +20,7 @@ import { createComputedAccountStatusAdapter, createDefaultChannelRuntimeState, } from "openclaw/plugin-sdk/status-helpers"; +import { isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime"; import { mattermostApprovalAuth } from "./approval-auth.js"; import { chunkTextForOutbound, @@ -30,7 +31,7 @@ import { type ChannelPlugin, } from "./channel-api.js"; import { MattermostChannelConfigSchema } from "./config-surface.js"; -import { collectMattermostMutableAllowlistWarnings } from "./doctor.js"; +import { mattermostDoctor } from "./doctor.js"; import { resolveMattermostGroupRequireMention } from "./group-mentions.js"; import { listMattermostAccountIds, @@ -330,9 +331,7 @@ export const mattermostPlugin: ChannelPlugin = create }), }, auth: mattermostApprovalAuth, - doctor: { - collectMutableAllowlistWarnings: collectMattermostMutableAllowlistWarnings, - }, + doctor: mattermostDoctor, groups: { resolveRequireMention: resolveMattermostGroupRequireMention, }, @@ -388,7 +387,7 @@ export const mattermostPlugin: ChannelPlugin = create baseUrl, token, timeoutMs, - account.config.allowPrivateNetwork === true, + isPrivateNetworkOptInEnabled(account.config), ); }, resolveAccountSnapshot: ({ account, runtime }) => ({ diff --git a/extensions/mattermost/src/config-schema-core.ts b/extensions/mattermost/src/config-schema-core.ts index b7d726278e0..3bc9c1bc601 100644 --- a/extensions/mattermost/src/config-schema-core.ts +++ b/extensions/mattermost/src/config-schema-core.ts @@ -70,6 +70,14 @@ const MattermostSlashCommandsSchema = z .strict() .optional(); +const MattermostNetworkSchema = z + .object({ + /** Dangerous opt-in for self-hosted Mattermost on trusted private/internal hosts. */ + dangerouslyAllowPrivateNetwork: z.boolean().optional(), + }) + .strict() + .optional(); + const MattermostAccountSchemaBase = z .object({ name: z.string().optional(), @@ -107,8 +115,8 @@ const MattermostAccountSchemaBase = z .optional(), /** Per-group configuration (keyed by Mattermost channel ID or "*" for default). */ groups: z.record(z.string(), MattermostGroupSchema.optional()).optional(), - /** Allow fetching from private/internal IP addresses (e.g. localhost). Required for self-hosted Mattermost on LAN/VPN. */ - allowPrivateNetwork: z.boolean().optional(), + /** Network policy overrides for self-hosted Mattermost on trusted private/internal hosts. */ + network: MattermostNetworkSchema, /** Retry configuration for DM channel creation */ dmChannelRetry: DmChannelRetrySchema, }) diff --git a/extensions/mattermost/src/config-schema.ts b/extensions/mattermost/src/config-schema.ts index 2e0cd0b3267..6f9855df2ba 100644 --- a/extensions/mattermost/src/config-schema.ts +++ b/extensions/mattermost/src/config-schema.ts @@ -70,6 +70,14 @@ const MattermostSlashCommandsSchema = z .strict() .optional(); +const MattermostNetworkSchema = z + .object({ + /** Dangerous opt-in for self-hosted Mattermost on trusted private/internal hosts. */ + dangerouslyAllowPrivateNetwork: z.boolean().optional(), + }) + .strict() + .optional(); + const MattermostAccountSchemaBase = z .object({ name: z.string().optional(), @@ -107,8 +115,8 @@ const MattermostAccountSchemaBase = z .optional(), /** Per-group configuration (keyed by Mattermost channel ID or "*" for default). */ groups: z.record(z.string(), MattermostGroupSchema.optional()).optional(), - /** Allow fetching from private/internal IP addresses (e.g. localhost). Required for self-hosted Mattermost on LAN/VPN. */ - allowPrivateNetwork: z.boolean().optional(), + /** Network policy overrides for self-hosted Mattermost on trusted private/internal hosts. */ + network: MattermostNetworkSchema, /** Retry configuration for DM channel creation */ dmChannelRetry: DmChannelRetrySchema, }) diff --git a/extensions/mattermost/src/doctor-contract.ts b/extensions/mattermost/src/doctor-contract.ts new file mode 100644 index 00000000000..c8f46c29fb7 --- /dev/null +++ b/extensions/mattermost/src/doctor-contract.ts @@ -0,0 +1,103 @@ +import type { + ChannelDoctorConfigMutation, + ChannelDoctorLegacyConfigRule, +} from "openclaw/plugin-sdk/channel-contract"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { + hasLegacyFlatAllowPrivateNetworkAlias, + migrateLegacyFlatAllowPrivateNetworkAlias, +} from "openclaw/plugin-sdk/ssrf-runtime"; + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function hasLegacyAllowPrivateNetworkInAccounts(value: unknown): boolean { + const accounts = isRecord(value) ? value : null; + return Boolean( + accounts && + Object.values(accounts).some((account) => + hasLegacyFlatAllowPrivateNetworkAlias(isRecord(account) ? account : {}), + ), + ); +} + +export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] = [ + { + path: ["channels", "mattermost"], + message: + "channels.mattermost.allowPrivateNetwork is legacy; use channels.mattermost.network.dangerouslyAllowPrivateNetwork instead (auto-migrated on load).", + match: (value) => hasLegacyFlatAllowPrivateNetworkAlias(isRecord(value) ? value : {}), + }, + { + path: ["channels", "mattermost", "accounts"], + message: + "channels.mattermost.accounts..allowPrivateNetwork is legacy; use channels.mattermost.accounts..network.dangerouslyAllowPrivateNetwork instead (auto-migrated on load).", + match: hasLegacyAllowPrivateNetworkInAccounts, + }, +]; + +export function normalizeCompatibilityConfig({ + cfg, +}: { + cfg: OpenClawConfig; +}): ChannelDoctorConfigMutation { + const channels = isRecord(cfg.channels) ? cfg.channels : null; + const mattermost = isRecord(channels?.mattermost) ? channels.mattermost : null; + if (!mattermost) { + return { config: cfg, changes: [] }; + } + + const changes: string[] = []; + let updatedMattermost = mattermost; + let changed = false; + + const topLevel = migrateLegacyFlatAllowPrivateNetworkAlias({ + entry: updatedMattermost, + pathPrefix: "channels.mattermost", + changes, + }); + updatedMattermost = topLevel.entry; + changed = changed || topLevel.changed; + + const accounts = isRecord(updatedMattermost.accounts) ? updatedMattermost.accounts : null; + if (accounts) { + let accountsChanged = false; + const nextAccounts: Record = { ...accounts }; + for (const [accountId, accountValue] of Object.entries(accounts)) { + const account = isRecord(accountValue) ? accountValue : null; + if (!account) { + continue; + } + const migrated = migrateLegacyFlatAllowPrivateNetworkAlias({ + entry: account, + pathPrefix: `channels.mattermost.accounts.${accountId}`, + changes, + }); + if (!migrated.changed) { + continue; + } + nextAccounts[accountId] = migrated.entry; + accountsChanged = true; + } + if (accountsChanged) { + updatedMattermost = { ...updatedMattermost, accounts: nextAccounts }; + changed = true; + } + } + + if (!changed) { + return { config: cfg, changes: [] }; + } + + return { + config: { + ...cfg, + channels: { + ...cfg.channels, + mattermost: updatedMattermost as NonNullable["mattermost"], + }, + }, + changes, + }; +} diff --git a/extensions/mattermost/src/doctor.test.ts b/extensions/mattermost/src/doctor.test.ts new file mode 100644 index 00000000000..d4ee595cccf --- /dev/null +++ b/extensions/mattermost/src/doctor.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { mattermostDoctor } from "./doctor.js"; + +describe("mattermost doctor", () => { + it("normalizes legacy private-network aliases", () => { + const normalize = mattermostDoctor.normalizeCompatibilityConfig; + expect(normalize).toBeDefined(); + if (!normalize) { + return; + } + + const result = normalize({ + cfg: { + channels: { + mattermost: { + allowPrivateNetwork: true, + accounts: { + work: { + allowPrivateNetwork: false, + }, + }, + }, + }, + } as never, + }); + + expect(result.config.channels?.mattermost?.network).toEqual({ + dangerouslyAllowPrivateNetwork: true, + }); + expect(result.config.channels?.mattermost?.accounts?.work?.network).toEqual({ + dangerouslyAllowPrivateNetwork: false, + }); + }); +}); diff --git a/extensions/mattermost/src/doctor.ts b/extensions/mattermost/src/doctor.ts index c679515735b..a1c6f604d7d 100644 --- a/extensions/mattermost/src/doctor.ts +++ b/extensions/mattermost/src/doctor.ts @@ -1,4 +1,18 @@ +import type { + ChannelDoctorAdapter, + ChannelDoctorConfigMutation, + ChannelDoctorLegacyConfigRule, +} from "openclaw/plugin-sdk/channel-contract"; import { createDangerousNameMatchingMutableAllowlistWarningCollector } from "openclaw/plugin-sdk/channel-policy"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { + hasLegacyFlatAllowPrivateNetworkAlias, + migrateLegacyFlatAllowPrivateNetworkAlias, +} from "openclaw/plugin-sdk/ssrf-runtime"; + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} function isMattermostMutableAllowEntry(raw: string): boolean { const text = raw.trim(); @@ -34,3 +48,95 @@ export const collectMattermostMutableAllowlistWarnings = }, ], }); + +function hasLegacyMattermostAllowPrivateNetworkInAccounts(value: unknown): boolean { + const accounts = isRecord(value) ? value : null; + return Boolean( + accounts && + Object.values(accounts).some((account) => + hasLegacyFlatAllowPrivateNetworkAlias(isRecord(account) ? account : {}), + ), + ); +} + +export const MATTERMOST_LEGACY_CONFIG_RULES: ChannelDoctorLegacyConfigRule[] = [ + { + path: ["channels", "mattermost"], + message: + "channels.mattermost.allowPrivateNetwork is legacy; use channels.mattermost.network.dangerouslyAllowPrivateNetwork instead (auto-migrated on load).", + match: (value) => hasLegacyFlatAllowPrivateNetworkAlias(isRecord(value) ? value : {}), + }, + { + path: ["channels", "mattermost", "accounts"], + message: + "channels.mattermost.accounts..allowPrivateNetwork is legacy; use channels.mattermost.accounts..network.dangerouslyAllowPrivateNetwork instead (auto-migrated on load).", + match: hasLegacyMattermostAllowPrivateNetworkInAccounts, + }, +]; + +export function normalizeMattermostCompatibilityConfig(cfg: OpenClawConfig): ChannelDoctorConfigMutation { + const channels = isRecord(cfg.channels) ? cfg.channels : null; + const mattermost = isRecord(channels?.mattermost) ? channels.mattermost : null; + if (!mattermost) { + return { config: cfg, changes: [] }; + } + + const changes: string[] = []; + let updatedMattermost = mattermost; + let changed = false; + + const topLevel = migrateLegacyFlatAllowPrivateNetworkAlias({ + entry: updatedMattermost, + pathPrefix: "channels.mattermost", + changes, + }); + updatedMattermost = topLevel.entry; + changed = changed || topLevel.changed; + + const accounts = isRecord(updatedMattermost.accounts) ? updatedMattermost.accounts : null; + if (accounts) { + let accountsChanged = false; + const nextAccounts: Record = { ...accounts }; + for (const [accountId, accountValue] of Object.entries(accounts)) { + const account = isRecord(accountValue) ? accountValue : null; + if (!account) { + continue; + } + const migrated = migrateLegacyFlatAllowPrivateNetworkAlias({ + entry: account, + pathPrefix: `channels.mattermost.accounts.${accountId}`, + changes, + }); + if (!migrated.changed) { + continue; + } + nextAccounts[accountId] = migrated.entry; + accountsChanged = true; + } + if (accountsChanged) { + updatedMattermost = { ...updatedMattermost, accounts: nextAccounts }; + changed = true; + } + } + + if (!changed) { + return { config: cfg, changes: [] }; + } + + return { + config: { + ...cfg, + channels: { + ...cfg.channels, + mattermost: updatedMattermost as NonNullable["mattermost"], + }, + }, + changes, + }; +} + +export const mattermostDoctor: ChannelDoctorAdapter = { + legacyConfigRules: MATTERMOST_LEGACY_CONFIG_RULES, + normalizeCompatibilityConfig: ({ cfg }) => normalizeMattermostCompatibilityConfig(cfg), + collectMutableAllowlistWarnings: collectMattermostMutableAllowlistWarnings, +}; diff --git a/extensions/mattermost/src/mattermost/client.ts b/extensions/mattermost/src/mattermost/client.ts index b6ef607b681..bd5116d9ec9 100644 --- a/extensions/mattermost/src/mattermost/client.ts +++ b/extensions/mattermost/src/mattermost/client.ts @@ -1,4 +1,7 @@ -import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; +import { + fetchWithSsrFGuard, + ssrfPolicyFromPrivateNetworkOptIn, +} from "openclaw/plugin-sdk/ssrf-runtime"; import { z } from "openclaw/plugin-sdk/zod"; export type MattermostFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise; @@ -116,7 +119,7 @@ export function createMattermostClient(params: { url, init, auditContext: "mattermost-api", - policy: params.allowPrivateNetwork ? { allowPrivateNetwork: true } : undefined, + policy: ssrfPolicyFromPrivateNetworkOptIn(params.allowPrivateNetwork), }); try { const bodyBytes = NULL_BODY_STATUSES.has(response.status) diff --git a/extensions/mattermost/src/mattermost/directory.ts b/extensions/mattermost/src/mattermost/directory.ts index b622f4a4a85..3eceba69713 100644 --- a/extensions/mattermost/src/mattermost/directory.ts +++ b/extensions/mattermost/src/mattermost/directory.ts @@ -1,3 +1,4 @@ +import { isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime"; import { listMattermostAccountIds, resolveMattermostAccount } from "./accounts.js"; import { createMattermostClient, @@ -27,7 +28,7 @@ function buildClient(params: { return createMattermostClient({ baseUrl: account.baseUrl, botToken: account.botToken, - allowPrivateNetwork: account.config?.allowPrivateNetwork === true, + allowPrivateNetwork: isPrivateNetworkOptInEnabled(account.config), }); } diff --git a/extensions/mattermost/src/mattermost/monitor.ts b/extensions/mattermost/src/mattermost/monitor.ts index 6d0b3b9b685..ee3e636d47c 100644 --- a/extensions/mattermost/src/mattermost/monitor.ts +++ b/extensions/mattermost/src/mattermost/monitor.ts @@ -1,3 +1,4 @@ +import { isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime"; import { getMattermostRuntime } from "../runtime.js"; import { resolveMattermostAccount, resolveMattermostReplyToMode } from "./accounts.js"; import { @@ -273,7 +274,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} const client = createMattermostClient({ baseUrl, botToken, - allowPrivateNetwork: account.config?.allowPrivateNetwork === true, + allowPrivateNetwork: isPrivateNetworkOptInEnabled(account.config), }); // Wait for the Mattermost API to accept our bot token before proceeding. diff --git a/extensions/mattermost/src/mattermost/probe.ts b/extensions/mattermost/src/mattermost/probe.ts index c68b18bdd76..0b0e72c4db4 100644 --- a/extensions/mattermost/src/mattermost/probe.ts +++ b/extensions/mattermost/src/mattermost/probe.ts @@ -1,4 +1,7 @@ -import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; +import { + fetchWithSsrFGuard, + ssrfPolicyFromPrivateNetworkOptIn, +} from "openclaw/plugin-sdk/ssrf-runtime"; import { normalizeMattermostBaseUrl, readMattermostError, type MattermostUser } from "./client.js"; import type { BaseProbeResult } from "./runtime-api.js"; @@ -33,7 +36,7 @@ export async function probeMattermost( signal: controller?.signal, }, auditContext: "mattermost-probe", - policy: allowPrivateNetwork ? { allowPrivateNetwork: true } : undefined, + policy: ssrfPolicyFromPrivateNetworkOptIn(allowPrivateNetwork), }); try { const elapsedMs = Date.now() - start; diff --git a/extensions/mattermost/src/mattermost/reactions.ts b/extensions/mattermost/src/mattermost/reactions.ts index d363d0580d8..87028c00929 100644 --- a/extensions/mattermost/src/mattermost/reactions.ts +++ b/extensions/mattermost/src/mattermost/reactions.ts @@ -1,3 +1,4 @@ +import { isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime"; import { resolveMattermostAccount } from "./accounts.js"; import { createMattermostClient, @@ -86,7 +87,7 @@ async function runMattermostReaction( baseUrl, botToken, fetchImpl: params.fetchImpl, - allowPrivateNetwork: resolved.config?.allowPrivateNetwork === true, + allowPrivateNetwork: isPrivateNetworkOptInEnabled(resolved.config), }); const cacheKey = `${baseUrl}:${botToken}`; diff --git a/extensions/mattermost/src/mattermost/send.ts b/extensions/mattermost/src/mattermost/send.ts index 7389a464771..60320c165f7 100644 --- a/extensions/mattermost/src/mattermost/send.ts +++ b/extensions/mattermost/src/mattermost/send.ts @@ -1,4 +1,5 @@ import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; +import { isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime"; import { convertMarkdownTables } from "openclaw/plugin-sdk/text-runtime"; import { getMattermostRuntime } from "../runtime.js"; import { resolveMattermostAccount } from "./accounts.js"; @@ -355,7 +356,7 @@ async function resolveMattermostSendContext( : undefined; const dmRetryOptions = mergeDmRetryOptions(accountRetryConfig, opts.dmRetryOptions); - const allowPrivateNetwork = account.config.allowPrivateNetwork === true; + const allowPrivateNetwork = isPrivateNetworkOptInEnabled(account.config); const channelId = await resolveTargetChannelId({ target, baseUrl, diff --git a/extensions/mattermost/src/mattermost/slash-http.ts b/extensions/mattermost/src/mattermost/slash-http.ts index 9527549c23a..b3c9349692b 100644 --- a/extensions/mattermost/src/mattermost/slash-http.ts +++ b/extensions/mattermost/src/mattermost/slash-http.ts @@ -7,6 +7,7 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import { safeEqualSecret } from "openclaw/plugin-sdk/browser-support"; +import { isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime"; import type { ResolvedMattermostAccount } from "../mattermost/accounts.js"; import { getMattermostRuntime } from "../runtime.js"; import { @@ -273,7 +274,7 @@ export function createSlashCommandHttpHandler(params: SlashHttpHandlerParams) { const client = createMattermostClient({ baseUrl: account.baseUrl ?? "", botToken: account.botToken ?? "", - allowPrivateNetwork: account.config?.allowPrivateNetwork === true, + allowPrivateNetwork: isPrivateNetworkOptInEnabled(account.config), }); const auth = await authorizeSlashInvocation({ diff --git a/extensions/mattermost/src/mattermost/target-resolution.ts b/extensions/mattermost/src/mattermost/target-resolution.ts index 080f33f6c56..c92789b152f 100644 --- a/extensions/mattermost/src/mattermost/target-resolution.ts +++ b/extensions/mattermost/src/mattermost/target-resolution.ts @@ -1,3 +1,4 @@ +import { isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime"; import { resolveMattermostAccount } from "./accounts.js"; import { createMattermostClient, @@ -82,7 +83,7 @@ export async function resolveMattermostOpaqueTarget(params: { const client = createMattermostClient({ baseUrl, botToken: token, - allowPrivateNetwork: account?.config?.allowPrivateNetwork === true, + allowPrivateNetwork: isPrivateNetworkOptInEnabled(account?.config), }); try { await fetchMattermostUser(client, input); diff --git a/extensions/mattermost/src/types.ts b/extensions/mattermost/src/types.ts index 4f471332b51..2569b0b967e 100644 --- a/extensions/mattermost/src/types.ts +++ b/extensions/mattermost/src/types.ts @@ -5,6 +5,10 @@ export type MattermostReplyToMode = "off" | "first" | "all"; export type MattermostChatTypeKey = "direct" | "channel" | "group"; export type MattermostChatMode = "oncall" | "onmessage" | "onchar"; +export type MattermostNetworkConfig = { + /** Dangerous opt-in for self-hosted Mattermost on trusted private/internal hosts. */ + dangerouslyAllowPrivateNetwork?: boolean; +}; export type MattermostAccountConfig = { /** Optional display name for this account (used in CLI/UI lists). */ @@ -86,8 +90,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; + /** Network policy overrides for self-hosted Mattermost on trusted private/internal hosts. */ + network?: MattermostNetworkConfig; /** Retry configuration for DM channel creation */ dmChannelRetry?: { /** Maximum number of retry attempts (default: 3) */ diff --git a/extensions/nextcloud-talk/contract-surfaces.ts b/extensions/nextcloud-talk/contract-surfaces.ts index bc8f64f050f..274710269c3 100644 --- a/extensions/nextcloud-talk/contract-surfaces.ts +++ b/extensions/nextcloud-talk/contract-surfaces.ts @@ -1,3 +1,4 @@ +export { normalizeCompatibilityConfig, legacyConfigRules } from "./src/doctor-contract.js"; export { collectRuntimeConfigAssignments, secretTargetRegistryEntries, diff --git a/extensions/nextcloud-talk/src/channel.ts b/extensions/nextcloud-talk/src/channel.ts index cd9de4c707e..cc7454ded06 100644 --- a/extensions/nextcloud-talk/src/channel.ts +++ b/extensions/nextcloud-talk/src/channel.ts @@ -33,6 +33,7 @@ import { type OpenClawConfig, } from "./channel-api.js"; import { NextcloudTalkConfigSchema } from "./config-schema.js"; +import { nextcloudTalkDoctor } from "./doctor.js"; import { monitorNextcloudTalkProvider } from "./monitor.js"; import { looksLikeNextcloudTalkTargetId, @@ -141,6 +142,7 @@ export const nextcloudTalkPlugin: ChannelPlugin = }), }, auth: nextcloudTalkApprovalAuth, + doctor: nextcloudTalkDoctor, groups: { resolveRequireMention: ({ cfg, accountId, groupId }) => { const account = resolveNextcloudTalkAccount({ cfg: cfg as CoreConfig, accountId }); diff --git a/extensions/nextcloud-talk/src/config-schema.ts b/extensions/nextcloud-talk/src/config-schema.ts index f05eb6fe344..13c104b635e 100644 --- a/extensions/nextcloud-talk/src/config-schema.ts +++ b/extensions/nextcloud-talk/src/config-schema.ts @@ -23,6 +23,14 @@ export const NextcloudTalkRoomSchema = z }) .strict(); +const NextcloudTalkNetworkSchema = z + .object({ + /** Dangerous opt-in for self-hosted Nextcloud Talk on trusted private/internal hosts. */ + dangerouslyAllowPrivateNetwork: z.boolean().optional(), + }) + .strict() + .optional(); + export const NextcloudTalkAccountSchemaBase = z .object({ name: z.string().optional(), @@ -43,8 +51,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(), + /** Network policy overrides for self-hosted Nextcloud Talk on trusted private/internal hosts. */ + network: NextcloudTalkNetworkSchema, ...ReplyRuntimeConfigSchemaShape, }) .strict(); diff --git a/extensions/nextcloud-talk/src/doctor-contract.ts b/extensions/nextcloud-talk/src/doctor-contract.ts new file mode 100644 index 00000000000..98c658b1a57 --- /dev/null +++ b/extensions/nextcloud-talk/src/doctor-contract.ts @@ -0,0 +1,104 @@ +import type { + ChannelDoctorConfigMutation, + ChannelDoctorLegacyConfigRule, +} from "openclaw/plugin-sdk/channel-contract"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { + hasLegacyFlatAllowPrivateNetworkAlias, + migrateLegacyFlatAllowPrivateNetworkAlias, +} from "openclaw/plugin-sdk/ssrf-runtime"; + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function hasLegacyAllowPrivateNetworkInAccounts(value: unknown): boolean { + const accounts = isRecord(value) ? value : null; + return Boolean( + accounts && + Object.values(accounts).some((account) => + hasLegacyFlatAllowPrivateNetworkAlias(isRecord(account) ? account : {}), + ), + ); +} + +export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] = [ + { + path: ["channels", "nextcloud-talk"], + message: + "channels.nextcloud-talk.allowPrivateNetwork is legacy; use channels.nextcloud-talk.network.dangerouslyAllowPrivateNetwork instead (auto-migrated on load).", + match: (value) => hasLegacyFlatAllowPrivateNetworkAlias(isRecord(value) ? value : {}), + }, + { + path: ["channels", "nextcloud-talk", "accounts"], + message: + "channels.nextcloud-talk.accounts..allowPrivateNetwork is legacy; use channels.nextcloud-talk.accounts..network.dangerouslyAllowPrivateNetwork instead (auto-migrated on load).", + match: hasLegacyAllowPrivateNetworkInAccounts, + }, +]; + +export function normalizeCompatibilityConfig({ + cfg, +}: { + cfg: OpenClawConfig; +}): ChannelDoctorConfigMutation { + const channels = isRecord(cfg.channels) ? cfg.channels : null; + const nextcloudTalk = isRecord(channels?.["nextcloud-talk"]) ? channels["nextcloud-talk"] : null; + if (!nextcloudTalk) { + return { config: cfg, changes: [] }; + } + + const changes: string[] = []; + let updatedNextcloudTalk = nextcloudTalk; + let changed = false; + + const topLevel = migrateLegacyFlatAllowPrivateNetworkAlias({ + entry: updatedNextcloudTalk, + pathPrefix: "channels.nextcloud-talk", + changes, + }); + updatedNextcloudTalk = topLevel.entry; + changed = changed || topLevel.changed; + + const accounts = isRecord(updatedNextcloudTalk.accounts) ? updatedNextcloudTalk.accounts : null; + if (accounts) { + let accountsChanged = false; + const nextAccounts: Record = { ...accounts }; + for (const [accountId, accountValue] of Object.entries(accounts)) { + const account = isRecord(accountValue) ? accountValue : null; + if (!account) { + continue; + } + const migrated = migrateLegacyFlatAllowPrivateNetworkAlias({ + entry: account, + pathPrefix: `channels.nextcloud-talk.accounts.${accountId}`, + changes, + }); + if (!migrated.changed) { + continue; + } + nextAccounts[accountId] = migrated.entry; + accountsChanged = true; + } + if (accountsChanged) { + updatedNextcloudTalk = { ...updatedNextcloudTalk, accounts: nextAccounts }; + changed = true; + } + } + + if (!changed) { + return { config: cfg, changes: [] }; + } + + return { + config: { + ...cfg, + channels: { + ...cfg.channels, + "nextcloud-talk": + updatedNextcloudTalk as NonNullable["nextcloud-talk"], + }, + }, + changes, + }; +} diff --git a/extensions/nextcloud-talk/src/doctor.test.ts b/extensions/nextcloud-talk/src/doctor.test.ts new file mode 100644 index 00000000000..355a3af408a --- /dev/null +++ b/extensions/nextcloud-talk/src/doctor.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { nextcloudTalkDoctor } from "./doctor.js"; + +describe("nextcloud-talk doctor", () => { + it("normalizes legacy private-network aliases", () => { + const normalize = nextcloudTalkDoctor.normalizeCompatibilityConfig; + expect(normalize).toBeDefined(); + if (!normalize) { + return; + } + + const result = normalize({ + cfg: { + channels: { + "nextcloud-talk": { + allowPrivateNetwork: true, + accounts: { + work: { + allowPrivateNetwork: false, + }, + }, + }, + }, + } as never, + }); + + expect(result.config.channels?.["nextcloud-talk"]?.network).toEqual({ + dangerouslyAllowPrivateNetwork: true, + }); + expect(result.config.channels?.["nextcloud-talk"]?.accounts?.work?.network).toEqual({ + dangerouslyAllowPrivateNetwork: false, + }); + }); +}); diff --git a/extensions/nextcloud-talk/src/doctor.ts b/extensions/nextcloud-talk/src/doctor.ts new file mode 100644 index 00000000000..a416858f90e --- /dev/null +++ b/extensions/nextcloud-talk/src/doctor.ts @@ -0,0 +1,106 @@ +import type { + ChannelDoctorAdapter, + ChannelDoctorConfigMutation, + ChannelDoctorLegacyConfigRule, +} from "openclaw/plugin-sdk/channel-contract"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { + hasLegacyFlatAllowPrivateNetworkAlias, + migrateLegacyFlatAllowPrivateNetworkAlias, +} from "openclaw/plugin-sdk/ssrf-runtime"; + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function hasLegacyAllowPrivateNetworkInAccounts(value: unknown): boolean { + const accounts = isRecord(value) ? value : null; + return Boolean( + accounts && + Object.values(accounts).some((account) => + hasLegacyFlatAllowPrivateNetworkAlias(isRecord(account) ? account : {}), + ), + ); +} + +function normalizeNextcloudTalkCompatibilityConfig(cfg: OpenClawConfig): ChannelDoctorConfigMutation { + const channels = isRecord(cfg.channels) ? cfg.channels : null; + const nextcloudTalk = isRecord(channels?.["nextcloud-talk"]) ? channels["nextcloud-talk"] : null; + if (!nextcloudTalk) { + return { config: cfg, changes: [] }; + } + + const changes: string[] = []; + let updatedNextcloudTalk = nextcloudTalk; + let changed = false; + + const topLevel = migrateLegacyFlatAllowPrivateNetworkAlias({ + entry: updatedNextcloudTalk, + pathPrefix: "channels.nextcloud-talk", + changes, + }); + updatedNextcloudTalk = topLevel.entry; + changed = changed || topLevel.changed; + + const accounts = isRecord(updatedNextcloudTalk.accounts) ? updatedNextcloudTalk.accounts : null; + if (accounts) { + let accountsChanged = false; + const nextAccounts: Record = { ...accounts }; + for (const [accountId, accountValue] of Object.entries(accounts)) { + const account = isRecord(accountValue) ? accountValue : null; + if (!account) { + continue; + } + const migrated = migrateLegacyFlatAllowPrivateNetworkAlias({ + entry: account, + pathPrefix: `channels.nextcloud-talk.accounts.${accountId}`, + changes, + }); + if (!migrated.changed) { + continue; + } + nextAccounts[accountId] = migrated.entry; + accountsChanged = true; + } + if (accountsChanged) { + updatedNextcloudTalk = { ...updatedNextcloudTalk, accounts: nextAccounts }; + changed = true; + } + } + + if (!changed) { + return { config: cfg, changes: [] }; + } + + return { + config: { + ...cfg, + channels: { + ...cfg.channels, + "nextcloud-talk": + updatedNextcloudTalk as NonNullable["nextcloud-talk"], + }, + }, + changes, + }; +} + +const NEXTCLOUD_TALK_LEGACY_CONFIG_RULES: ChannelDoctorLegacyConfigRule[] = [ + { + path: ["channels", "nextcloud-talk"], + message: + "channels.nextcloud-talk.allowPrivateNetwork is legacy; use channels.nextcloud-talk.network.dangerouslyAllowPrivateNetwork instead (auto-migrated on load).", + match: (value) => hasLegacyFlatAllowPrivateNetworkAlias(isRecord(value) ? value : {}), + }, + { + path: ["channels", "nextcloud-talk", "accounts"], + message: + "channels.nextcloud-talk.accounts..allowPrivateNetwork is legacy; use channels.nextcloud-talk.accounts..network.dangerouslyAllowPrivateNetwork instead (auto-migrated on load).", + match: hasLegacyAllowPrivateNetworkInAccounts, + }, +]; + +export const nextcloudTalkDoctor: ChannelDoctorAdapter = { + legacyConfigRules: NEXTCLOUD_TALK_LEGACY_CONFIG_RULES, + normalizeCompatibilityConfig: ({ cfg }) => normalizeNextcloudTalkCompatibilityConfig(cfg), +}; diff --git a/extensions/nextcloud-talk/src/room-info.ts b/extensions/nextcloud-talk/src/room-info.ts index 9308756c012..7d9bbdcd24a 100644 --- a/extensions/nextcloud-talk/src/room-info.ts +++ b/extensions/nextcloud-talk/src/room-info.ts @@ -1,4 +1,5 @@ import { readFileSync } from "node:fs"; +import { ssrfPolicyFromPrivateNetworkOptIn } from "openclaw/plugin-sdk/ssrf-runtime"; import { fetchWithSsrFGuard, type RuntimeEnv } from "../runtime-api.js"; import type { ResolvedNextcloudTalkAccount } from "./accounts.js"; import { normalizeResolvedSecretInputString } from "./secret-input.js"; @@ -111,7 +112,7 @@ export async function resolveNextcloudTalkRoomKind(params: { }, }, auditContext: "nextcloud-talk.room-info", - policy: account.config?.allowPrivateNetwork ? { allowPrivateNetwork: true } : undefined, + policy: ssrfPolicyFromPrivateNetworkOptIn(account.config), }); try { if (!response.ok) { diff --git a/extensions/nextcloud-talk/src/send.runtime.ts b/extensions/nextcloud-talk/src/send.runtime.ts index 73ef232088d..6ced3f2c905 100644 --- a/extensions/nextcloud-talk/src/send.runtime.ts +++ b/extensions/nextcloud-talk/src/send.runtime.ts @@ -1,4 +1,5 @@ export { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; +export { ssrfPolicyFromPrivateNetworkOptIn } from "openclaw/plugin-sdk/ssrf-runtime"; export { convertMarkdownTables } from "openclaw/plugin-sdk/text-runtime"; export { fetchWithSsrFGuard } from "../runtime-api.js"; export { resolveNextcloudTalkAccount } from "./accounts.js"; diff --git a/extensions/nextcloud-talk/src/send.ts b/extensions/nextcloud-talk/src/send.ts index 05416e3cacb..b5bd84501a4 100644 --- a/extensions/nextcloud-talk/src/send.ts +++ b/extensions/nextcloud-talk/src/send.ts @@ -6,6 +6,7 @@ import { getNextcloudTalkRuntime, resolveMarkdownTableMode, resolveNextcloudTalkAccount, + ssrfPolicyFromPrivateNetworkOptIn, } from "./send.runtime.js"; import type { CoreConfig, NextcloudTalkSendResult } from "./types.js"; @@ -130,7 +131,7 @@ export async function sendMessageNextcloudTalk( body: bodyStr, }, auditContext: "nextcloud-talk-send", - policy: account.config?.allowPrivateNetwork ? { allowPrivateNetwork: true } : undefined, + policy: ssrfPolicyFromPrivateNetworkOptIn(account.config), }); try { @@ -218,7 +219,7 @@ export async function sendReactionNextcloudTalk( body, }, auditContext: "nextcloud-talk-reaction", - policy: account.config?.allowPrivateNetwork ? { allowPrivateNetwork: true } : undefined, + policy: ssrfPolicyFromPrivateNetworkOptIn(account.config), }); try { diff --git a/extensions/nextcloud-talk/src/types.ts b/extensions/nextcloud-talk/src/types.ts index c38deb49dc3..e271fc590df 100644 --- a/extensions/nextcloud-talk/src/types.ts +++ b/extensions/nextcloud-talk/src/types.ts @@ -22,6 +22,11 @@ export type NextcloudTalkRoomConfig = { systemPrompt?: string; }; +export type NextcloudTalkNetworkConfig = { + /** Dangerous opt-in for self-hosted Nextcloud Talk on trusted private/internal hosts. */ + dangerouslyAllowPrivateNetwork?: boolean; +}; + export type NextcloudTalkAccountConfig = { /** Optional display name for this account (used in CLI/UI lists). */ name?: string; @@ -75,8 +80,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; + /** Network policy overrides for self-hosted Nextcloud Talk on trusted private/internal hosts. */ + network?: NextcloudTalkNetworkConfig; }; export type NextcloudTalkConfig = { diff --git a/extensions/tlon/contract-surfaces.ts b/extensions/tlon/contract-surfaces.ts new file mode 100644 index 00000000000..a7a56f23442 --- /dev/null +++ b/extensions/tlon/contract-surfaces.ts @@ -0,0 +1 @@ +export { normalizeCompatibilityConfig, legacyConfigRules } from "./src/doctor-contract.js"; diff --git a/extensions/tlon/src/account-fields.ts b/extensions/tlon/src/account-fields.ts index cbddd1d37b3..1888db2e091 100644 --- a/extensions/tlon/src/account-fields.ts +++ b/extensions/tlon/src/account-fields.ts @@ -15,7 +15,11 @@ export function buildTlonAccountFields(input: TlonAccountFieldsInput) { ...(input.url ? { url: input.url } : {}), ...(input.code ? { code: input.code } : {}), ...(typeof input.allowPrivateNetwork === "boolean" - ? { allowPrivateNetwork: input.allowPrivateNetwork } + ? { + network: { + dangerouslyAllowPrivateNetwork: input.allowPrivateNetwork, + }, + } : {}), ...(input.groupChannels ? { groupChannels: input.groupChannels } : {}), ...(input.dmAllowlist ? { dmAllowlist: input.dmAllowlist } : {}), diff --git a/extensions/tlon/src/channel.ts b/extensions/tlon/src/channel.ts index d2fc6bcfa31..ad08ce19e6f 100644 --- a/extensions/tlon/src/channel.ts +++ b/extensions/tlon/src/channel.ts @@ -17,6 +17,7 @@ import { resolveTlonSetupConfigured, tlonSetupAdapter, } from "./setup-core.js"; +import { tlonDoctor } from "./doctor.js"; import { formatTargetHint, normalizeShip, @@ -100,6 +101,7 @@ export const tlonPlugin = createChatChannelPlugin({ }, }), }, + doctor: tlonDoctor, messaging: { normalizeTarget: (target) => { const parsed = parseTlonTarget(target); diff --git a/extensions/tlon/src/config-schema.ts b/extensions/tlon/src/config-schema.ts index d170631540f..839f57b7abf 100644 --- a/extensions/tlon/src/config-schema.ts +++ b/extensions/tlon/src/config-schema.ts @@ -13,13 +13,20 @@ export const TlonAuthorizationSchema = z.object({ channelRules: z.record(z.string(), TlonChannelRuleSchema).optional(), }); +const TlonNetworkSchema = z + .object({ + dangerouslyAllowPrivateNetwork: z.boolean().optional(), + }) + .strict() + .optional(); + const tlonCommonConfigFields = { name: z.string().optional(), enabled: z.boolean().optional(), ship: ShipSchema.optional(), url: z.string().optional(), code: z.string().optional(), - allowPrivateNetwork: z.boolean().optional(), + network: TlonNetworkSchema, groupChannels: z.array(ChannelNestSchema).optional(), dmAllowlist: z.array(ShipSchema).optional(), autoDiscoverChannels: z.boolean().optional(), diff --git a/extensions/tlon/src/core.test.ts b/extensions/tlon/src/core.test.ts index dfc9a87c900..927c17ce128 100644 --- a/extensions/tlon/src/core.test.ts +++ b/extensions/tlon/src/core.test.ts @@ -122,7 +122,7 @@ describe("tlon core", () => { ]); expect(result.cfg.channels?.tlon?.dmAllowlist).toEqual(["~zod", "~nec"]); expect(result.cfg.channels?.tlon?.autoDiscoverChannels).toBe(true); - expect(result.cfg.channels?.tlon?.allowPrivateNetwork).toBe(false); + expect(result.cfg.channels?.tlon?.network?.dangerouslyAllowPrivateNetwork).toBe(false); }); it("resolves dm targets to normalized ships", () => { diff --git a/extensions/tlon/src/doctor-contract.ts b/extensions/tlon/src/doctor-contract.ts new file mode 100644 index 00000000000..112ea5ffa82 --- /dev/null +++ b/extensions/tlon/src/doctor-contract.ts @@ -0,0 +1,103 @@ +import type { + ChannelDoctorConfigMutation, + ChannelDoctorLegacyConfigRule, +} from "openclaw/plugin-sdk/channel-contract"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { + hasLegacyFlatAllowPrivateNetworkAlias, + migrateLegacyFlatAllowPrivateNetworkAlias, +} from "openclaw/plugin-sdk/ssrf-runtime"; + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function hasLegacyAllowPrivateNetworkInAccounts(value: unknown): boolean { + const accounts = isRecord(value) ? value : null; + return Boolean( + accounts && + Object.values(accounts).some((account) => + hasLegacyFlatAllowPrivateNetworkAlias(isRecord(account) ? account : {}), + ), + ); +} + +export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] = [ + { + path: ["channels", "tlon"], + message: + "channels.tlon.allowPrivateNetwork is legacy; use channels.tlon.network.dangerouslyAllowPrivateNetwork instead (auto-migrated on load).", + match: (value) => hasLegacyFlatAllowPrivateNetworkAlias(isRecord(value) ? value : {}), + }, + { + path: ["channels", "tlon", "accounts"], + message: + "channels.tlon.accounts..allowPrivateNetwork is legacy; use channels.tlon.accounts..network.dangerouslyAllowPrivateNetwork instead (auto-migrated on load).", + match: hasLegacyAllowPrivateNetworkInAccounts, + }, +]; + +export function normalizeCompatibilityConfig({ + cfg, +}: { + cfg: OpenClawConfig; +}): ChannelDoctorConfigMutation { + const channels = isRecord(cfg.channels) ? cfg.channels : null; + const tlon = isRecord(channels?.tlon) ? channels.tlon : null; + if (!tlon) { + return { config: cfg, changes: [] }; + } + + const changes: string[] = []; + let updatedTlon = tlon; + let changed = false; + + const topLevel = migrateLegacyFlatAllowPrivateNetworkAlias({ + entry: updatedTlon, + pathPrefix: "channels.tlon", + changes, + }); + updatedTlon = topLevel.entry; + changed = changed || topLevel.changed; + + const accounts = isRecord(updatedTlon.accounts) ? updatedTlon.accounts : null; + if (accounts) { + let accountsChanged = false; + const nextAccounts: Record = { ...accounts }; + for (const [accountId, accountValue] of Object.entries(accounts)) { + const account = isRecord(accountValue) ? accountValue : null; + if (!account) { + continue; + } + const migrated = migrateLegacyFlatAllowPrivateNetworkAlias({ + entry: account, + pathPrefix: `channels.tlon.accounts.${accountId}`, + changes, + }); + if (!migrated.changed) { + continue; + } + nextAccounts[accountId] = migrated.entry; + accountsChanged = true; + } + if (accountsChanged) { + updatedTlon = { ...updatedTlon, accounts: nextAccounts }; + changed = true; + } + } + + if (!changed) { + return { config: cfg, changes: [] }; + } + + return { + config: { + ...cfg, + channels: { + ...cfg.channels, + tlon: updatedTlon as NonNullable["tlon"], + }, + }, + changes, + }; +} diff --git a/extensions/tlon/src/doctor.test.ts b/extensions/tlon/src/doctor.test.ts new file mode 100644 index 00000000000..64f39138072 --- /dev/null +++ b/extensions/tlon/src/doctor.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { tlonDoctor } from "./doctor.js"; + +describe("tlon doctor", () => { + it("normalizes legacy private-network aliases", () => { + const normalize = tlonDoctor.normalizeCompatibilityConfig; + expect(normalize).toBeDefined(); + if (!normalize) { + return; + } + + const result = normalize({ + cfg: { + channels: { + tlon: { + allowPrivateNetwork: true, + accounts: { + alt: { + allowPrivateNetwork: false, + }, + }, + }, + }, + } as never, + }); + + expect(result.config.channels?.tlon?.network).toEqual({ + dangerouslyAllowPrivateNetwork: true, + }); + expect(result.config.channels?.tlon?.accounts?.alt?.network).toEqual({ + dangerouslyAllowPrivateNetwork: false, + }); + }); +}); diff --git a/extensions/tlon/src/doctor.ts b/extensions/tlon/src/doctor.ts new file mode 100644 index 00000000000..bacc3615b53 --- /dev/null +++ b/extensions/tlon/src/doctor.ts @@ -0,0 +1,105 @@ +import type { + ChannelDoctorAdapter, + ChannelDoctorConfigMutation, + ChannelDoctorLegacyConfigRule, +} from "openclaw/plugin-sdk/channel-contract"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { + hasLegacyFlatAllowPrivateNetworkAlias, + migrateLegacyFlatAllowPrivateNetworkAlias, +} from "openclaw/plugin-sdk/ssrf-runtime"; + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function hasLegacyAllowPrivateNetworkInAccounts(value: unknown): boolean { + const accounts = isRecord(value) ? value : null; + return Boolean( + accounts && + Object.values(accounts).some((account) => + hasLegacyFlatAllowPrivateNetworkAlias(isRecord(account) ? account : {}), + ), + ); +} + +function normalizeTlonCompatibilityConfig(cfg: OpenClawConfig): ChannelDoctorConfigMutation { + const channels = isRecord(cfg.channels) ? cfg.channels : null; + const tlon = isRecord(channels?.tlon) ? channels.tlon : null; + if (!tlon) { + return { config: cfg, changes: [] }; + } + + const changes: string[] = []; + let updatedTlon = tlon; + let changed = false; + + const topLevel = migrateLegacyFlatAllowPrivateNetworkAlias({ + entry: updatedTlon, + pathPrefix: "channels.tlon", + changes, + }); + updatedTlon = topLevel.entry; + changed = changed || topLevel.changed; + + const accounts = isRecord(updatedTlon.accounts) ? updatedTlon.accounts : null; + if (accounts) { + let accountsChanged = false; + const nextAccounts: Record = { ...accounts }; + for (const [accountId, accountValue] of Object.entries(accounts)) { + const account = isRecord(accountValue) ? accountValue : null; + if (!account) { + continue; + } + const migrated = migrateLegacyFlatAllowPrivateNetworkAlias({ + entry: account, + pathPrefix: `channels.tlon.accounts.${accountId}`, + changes, + }); + if (!migrated.changed) { + continue; + } + nextAccounts[accountId] = migrated.entry; + accountsChanged = true; + } + if (accountsChanged) { + updatedTlon = { ...updatedTlon, accounts: nextAccounts }; + changed = true; + } + } + + if (!changed) { + return { config: cfg, changes: [] }; + } + + return { + config: { + ...cfg, + channels: { + ...cfg.channels, + tlon: updatedTlon as NonNullable["tlon"], + }, + }, + changes, + }; +} + +const TLON_LEGACY_CONFIG_RULES: ChannelDoctorLegacyConfigRule[] = [ + { + path: ["channels", "tlon"], + message: + "channels.tlon.allowPrivateNetwork is legacy; use channels.tlon.network.dangerouslyAllowPrivateNetwork instead (auto-migrated on load).", + match: (value) => hasLegacyFlatAllowPrivateNetworkAlias(isRecord(value) ? value : {}), + }, + { + path: ["channels", "tlon", "accounts"], + message: + "channels.tlon.accounts..allowPrivateNetwork is legacy; use channels.tlon.accounts..network.dangerouslyAllowPrivateNetwork instead (auto-migrated on load).", + match: hasLegacyAllowPrivateNetworkInAccounts, + }, +]; + +export const tlonDoctor: ChannelDoctorAdapter = { + legacyConfigRules: TLON_LEGACY_CONFIG_RULES, + normalizeCompatibilityConfig: ({ cfg }) => normalizeTlonCompatibilityConfig(cfg), +}; diff --git a/extensions/tlon/src/setup-surface.ts b/extensions/tlon/src/setup-surface.ts index 28f09390bd0..96aa9bc724b 100644 --- a/extensions/tlon/src/setup-surface.ts +++ b/extensions/tlon/src/setup-surface.ts @@ -46,7 +46,7 @@ export const tlonSetupWizard = createTlonSetupWizardBase({ initialValue: allowPrivateNetwork, }); if (!allowPrivateNetwork) { - throw new Error("Refusing private/internal Ship URL without explicit approval"); + throw new Error("Refusing private/internal ship URL without explicit network opt-in"); } } next = applyTlonSetupConfig({ diff --git a/extensions/tlon/src/types.ts b/extensions/tlon/src/types.ts index 4e1e7c8fd6b..d9a3e39baab 100644 --- a/extensions/tlon/src/types.ts +++ b/extensions/tlon/src/types.ts @@ -5,6 +5,7 @@ import { resolveMergedAccountConfig, } from "openclaw/plugin-sdk/account-resolution"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { hasLegacyFlatAllowPrivateNetworkAlias, isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime"; type TlonAccountConfig = { name?: string; @@ -12,7 +13,9 @@ type TlonAccountConfig = { ship?: string; url?: string; code?: string; - allowPrivateNetwork?: boolean; + network?: { + dangerouslyAllowPrivateNetwork?: boolean; + }; groupChannels?: string[]; dmAllowlist?: string[]; groupInviteAllowlist?: string[]; @@ -102,7 +105,15 @@ export function resolveTlonAccount( const ship = (merged.ship ?? null) as string | null; const url = (merged.url ?? null) as string | null; const code = (merged.code ?? null) as string | null; - const allowPrivateNetwork = (merged.allowPrivateNetwork ?? null) as boolean | null; + const allowPrivateNetwork = + isPrivateNetworkOptInEnabled(merged) + ? true + : typeof merged.network?.dangerouslyAllowPrivateNetwork === "boolean" + ? merged.network.dangerouslyAllowPrivateNetwork + : hasLegacyFlatAllowPrivateNetworkAlias(merged) && + typeof merged.allowPrivateNetwork === "boolean" + ? merged.allowPrivateNetwork + : null; const groupChannels = (merged.groupChannels ?? []) as string[]; const dmAllowlist = (merged.dmAllowlist ?? []) as string[]; const groupInviteAllowlist = (merged.groupInviteAllowlist ?? []) as string[]; diff --git a/src/channels/plugins/contract-surfaces.test.ts b/src/channels/plugins/contract-surfaces.test.ts index bbe93d26590..7a3b5092854 100644 --- a/src/channels/plugins/contract-surfaces.test.ts +++ b/src/channels/plugins/contract-surfaces.test.ts @@ -13,4 +13,24 @@ describe("bundled channel contract surfaces", () => { expect(surface).not.toBeNull(); expect(surface?.normalizeTelegramCommandName?.("/Hello-World")).toBe("hello_world"); }); + + it.each(["matrix", "mattermost", "bluebubbles", "nextcloud-talk", "tlon"])( + "exposes legacy migration hooks for %s from a source checkout", + (pluginId) => { + const surface = getBundledChannelContractSurfaceModule<{ + normalizeCompatibilityConfig?: (params: { cfg: Record }) => { + config: Record; + changes: string[]; + }; + legacyConfigRules?: unknown[]; + }>({ + pluginId, + preferredBasename: "contract-surfaces.ts", + }); + + expect(surface).not.toBeNull(); + expect(surface?.normalizeCompatibilityConfig).toBeTypeOf("function"); + expect(Array.isArray(surface?.legacyConfigRules)).toBe(true); + }, + ); }); diff --git a/src/channels/plugins/legacy-config.test.ts b/src/channels/plugins/legacy-config.test.ts new file mode 100644 index 00000000000..52c19d74c0e --- /dev/null +++ b/src/channels/plugins/legacy-config.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { applyChannelDoctorCompatibilityMigrations } from "./legacy-config.js"; + +describe("bundled channel legacy config migrations", () => { + it("normalizes legacy private-network aliases exposed through bundled contract surfaces", () => { + const result = applyChannelDoctorCompatibilityMigrations({ + channels: { + mattermost: { + allowPrivateNetwork: true, + accounts: { + work: { + allowPrivateNetwork: false, + }, + }, + }, + }, + }); + + expect(result.next.channels?.mattermost).toEqual({ + network: { + dangerouslyAllowPrivateNetwork: true, + }, + accounts: { + work: { + network: { + dangerouslyAllowPrivateNetwork: false, + }, + }, + }, + }); + expect(result.changes).toEqual( + expect.arrayContaining([ + "Moved channels.mattermost.allowPrivateNetwork → channels.mattermost.network.dangerouslyAllowPrivateNetwork (true).", + "Moved channels.mattermost.accounts.work.allowPrivateNetwork → channels.mattermost.accounts.work.network.dangerouslyAllowPrivateNetwork (false).", + ]), + ); + }); +}); diff --git a/src/config/bundled-channel-config-metadata.generated.ts b/src/config/bundled-channel-config-metadata.generated.ts index cdb91a566ff..3197c892dc5 100644 --- a/src/config/bundled-channel-config-metadata.generated.ts +++ b/src/config/bundled-channel-config-metadata.generated.ts @@ -232,8 +232,14 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [ sendReadReceipts: { type: "boolean", }, - allowPrivateNetwork: { - type: "boolean", + network: { + type: "object", + properties: { + dangerouslyAllowPrivateNetwork: { + type: "boolean", + }, + }, + additionalProperties: false, }, blockStreaming: { type: "boolean", @@ -503,8 +509,14 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [ sendReadReceipts: { type: "boolean", }, - allowPrivateNetwork: { - type: "boolean", + network: { + type: "object", + properties: { + dangerouslyAllowPrivateNetwork: { + type: "boolean", + }, + }, + additionalProperties: false, }, blockStreaming: { type: "boolean", @@ -6473,8 +6485,14 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [ homeserver: { type: "string", }, - allowPrivateNetwork: { - type: "boolean", + network: { + type: "object", + properties: { + dangerouslyAllowPrivateNetwork: { + type: "boolean", + }, + }, + additionalProperties: false, }, proxy: { type: "string", @@ -7272,8 +7290,29 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [ }, additionalProperties: false, }, - allowPrivateNetwork: { - type: "boolean", + groups: { + type: "object", + propertyNames: { + type: "string", + }, + additionalProperties: { + type: "object", + properties: { + requireMention: { + type: "boolean", + }, + }, + additionalProperties: false, + }, + }, + network: { + type: "object", + properties: { + dangerouslyAllowPrivateNetwork: { + type: "boolean", + }, + }, + additionalProperties: false, }, dmChannelRetry: { type: "object", @@ -7553,8 +7592,29 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [ }, additionalProperties: false, }, - allowPrivateNetwork: { - type: "boolean", + groups: { + type: "object", + propertyNames: { + type: "string", + }, + additionalProperties: { + type: "object", + properties: { + requireMention: { + type: "boolean", + }, + }, + additionalProperties: false, + }, + }, + network: { + type: "object", + properties: { + dangerouslyAllowPrivateNetwork: { + type: "boolean", + }, + }, + additionalProperties: false, }, dmChannelRetry: { type: "object", @@ -7753,6 +7813,9 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [ type: "string", enum: ["length", "newline"], }, + typingIndicator: { + type: "boolean", + }, blockStreaming: { type: "boolean", }, @@ -8303,8 +8366,14 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [ additionalProperties: false, }, }, - allowPrivateNetwork: { - type: "boolean", + network: { + type: "object", + properties: { + dangerouslyAllowPrivateNetwork: { + type: "boolean", + }, + }, + additionalProperties: false, }, historyLimit: { type: "integer", @@ -8638,8 +8707,14 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [ additionalProperties: false, }, }, - allowPrivateNetwork: { - type: "boolean", + network: { + type: "object", + properties: { + dangerouslyAllowPrivateNetwork: { + type: "boolean", + }, + }, + additionalProperties: false, }, historyLimit: { type: "integer", @@ -13905,8 +13980,14 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [ code: { type: "string", }, - allowPrivateNetwork: { - type: "boolean", + network: { + type: "object", + properties: { + dangerouslyAllowPrivateNetwork: { + type: "boolean", + }, + }, + additionalProperties: false, }, groupChannels: { type: "array", @@ -14001,8 +14082,14 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [ code: { type: "string", }, - allowPrivateNetwork: { - type: "boolean", + network: { + type: "object", + properties: { + dangerouslyAllowPrivateNetwork: { + type: "boolean", + }, + }, + additionalProperties: false, }, groupChannels: { type: "array", diff --git a/src/config/legacy-migrate.test.ts b/src/config/legacy-migrate.test.ts index 72456e0bcc3..5421086c170 100644 --- a/src/config/legacy-migrate.test.ts +++ b/src/config/legacy-migrate.test.ts @@ -771,6 +771,75 @@ describe("legacy migrate nested channel enabled aliases", () => { }); }); +describe("legacy migrate bundled channel private-network aliases", () => { + it("accepts legacy Mattermost private-network aliases through validation and normalizes them", () => { + const raw = { + channels: { + mattermost: { + allowPrivateNetwork: true, + accounts: { + work: { + allowPrivateNetwork: false, + }, + }, + }, + }, + }; + + const validated = validateConfigObjectWithPlugins(raw); + expect(validated.ok).toBe(true); + if (!validated.ok) { + return; + } + expect(validated.config.channels?.mattermost).toEqual({ + dmPolicy: "pairing", + groupPolicy: "allowlist", + network: { + dangerouslyAllowPrivateNetwork: true, + }, + accounts: { + work: { + dmPolicy: "pairing", + groupPolicy: "allowlist", + network: { + dangerouslyAllowPrivateNetwork: false, + }, + }, + }, + }); + + const rawValidated = validateConfigObjectRawWithPlugins(raw); + expect(rawValidated.ok).toBe(true); + if (!rawValidated.ok) { + return; + } + expect(rawValidated.config.channels?.mattermost).toEqual({ + dmPolicy: "pairing", + groupPolicy: "allowlist", + network: { + dangerouslyAllowPrivateNetwork: true, + }, + accounts: { + work: { + dmPolicy: "pairing", + groupPolicy: "allowlist", + network: { + dangerouslyAllowPrivateNetwork: false, + }, + }, + }, + }); + + const res = migrateLegacyConfig(raw); + expect(res.changes).toEqual( + expect.arrayContaining([ + "Moved channels.mattermost.allowPrivateNetwork → channels.mattermost.network.dangerouslyAllowPrivateNetwork (true).", + "Moved channels.mattermost.accounts.work.allowPrivateNetwork → channels.mattermost.accounts.work.network.dangerouslyAllowPrivateNetwork (false).", + ]), + ); + }); +}); + describe("legacy migrate x_search auth", () => { it("moves only legacy x_search auth into plugin-owned xai config", () => { const res = migrateLegacyConfig({ diff --git a/src/plugin-sdk/ssrf-policy.test.ts b/src/plugin-sdk/ssrf-policy.test.ts index 64ccb0eb904..cd112e63db3 100644 --- a/src/plugin-sdk/ssrf-policy.test.ts +++ b/src/plugin-sdk/ssrf-policy.test.ts @@ -3,9 +3,13 @@ import type { LookupFn } from "../infra/net/ssrf.js"; import { assertHttpUrlTargetsPrivateNetwork, buildHostnameAllowlistPolicyFromSuffixAllowlist, + hasLegacyFlatAllowPrivateNetworkAlias, + isPrivateNetworkOptInEnabled, isHttpsUrlAllowedByHostnameSuffixAllowlist, + migrateLegacyFlatAllowPrivateNetworkAlias, normalizeHostnameSuffixAllowlist, ssrfPolicyFromAllowPrivateNetwork, + ssrfPolicyFromPrivateNetworkOptIn, } from "./ssrf-policy.js"; function createLookupFn(addresses: Array<{ address: string; family: number }>): LookupFn { @@ -39,6 +43,137 @@ describe("ssrfPolicyFromAllowPrivateNetwork", () => { }); }); +describe("isPrivateNetworkOptInEnabled", () => { + it.each([ + { + name: "returns false for missing input", + input: undefined, + expected: false, + }, + { + name: "returns false for explicit false", + input: false, + expected: false, + }, + { + name: "returns true for explicit boolean true", + input: true, + expected: true, + }, + { + name: "returns true for flat allowPrivateNetwork config", + input: { allowPrivateNetwork: true }, + expected: true, + }, + { + name: "returns true for flat dangerous opt-in config", + input: { dangerouslyAllowPrivateNetwork: true }, + expected: true, + }, + { + name: "returns true for nested network dangerous opt-in config", + input: { network: { dangerouslyAllowPrivateNetwork: true } }, + expected: true, + }, + { + name: "returns false for nested false values", + input: { network: { dangerouslyAllowPrivateNetwork: false } }, + expected: false, + }, + ])("$name", ({ input, expected }) => { + expect(isPrivateNetworkOptInEnabled(input)).toBe(expected); + }); +}); + +describe("ssrfPolicyFromPrivateNetworkOptIn", () => { + it.each([ + { + name: "returns undefined for unset input", + input: undefined, + expected: undefined, + }, + { + name: "returns undefined for explicit false input", + input: { allowPrivateNetwork: false }, + expected: undefined, + }, + { + name: "returns the compat policy for nested dangerous input", + input: { network: { dangerouslyAllowPrivateNetwork: true } }, + expected: { allowPrivateNetwork: true }, + }, + ])("$name", ({ input, expected }) => { + expect(ssrfPolicyFromPrivateNetworkOptIn(input)).toEqual(expected); + }); +}); + +describe("legacy private-network alias helpers", () => { + it("detects the flat allowPrivateNetwork alias", () => { + expect(hasLegacyFlatAllowPrivateNetworkAlias({ allowPrivateNetwork: true })).toBe(true); + expect(hasLegacyFlatAllowPrivateNetworkAlias({ network: {} })).toBe(false); + }); + + it("migrates the flat alias into network.dangerouslyAllowPrivateNetwork", () => { + const changes: string[] = []; + const migrated = migrateLegacyFlatAllowPrivateNetworkAlias({ + entry: { allowPrivateNetwork: true }, + pathPrefix: "channels.matrix", + changes, + }); + + expect(migrated.entry).toEqual({ + network: { + dangerouslyAllowPrivateNetwork: true, + }, + }); + expect(changes).toEqual([ + "Moved channels.matrix.allowPrivateNetwork → channels.matrix.network.dangerouslyAllowPrivateNetwork (true).", + ]); + }); + + it("prefers the canonical network key when both old and new keys are present", () => { + const changes: string[] = []; + const migrated = migrateLegacyFlatAllowPrivateNetworkAlias({ + entry: { + allowPrivateNetwork: true, + network: { + dangerouslyAllowPrivateNetwork: false, + }, + }, + pathPrefix: "channels.matrix.accounts.default", + changes, + }); + + expect(migrated.entry).toEqual({ + network: { + dangerouslyAllowPrivateNetwork: false, + }, + }); + expect(changes[0]).toContain("(false)"); + }); + + it("keeps an explicit canonical true when the legacy key is false", () => { + const changes: string[] = []; + const migrated = migrateLegacyFlatAllowPrivateNetworkAlias({ + entry: { + allowPrivateNetwork: false, + network: { + dangerouslyAllowPrivateNetwork: true, + }, + }, + pathPrefix: "channels.matrix.accounts.default", + changes, + }); + + expect(migrated.entry).toEqual({ + network: { + dangerouslyAllowPrivateNetwork: true, + }, + }); + expect(changes[0]).toContain("(true)"); + }); +}); + describe("assertHttpUrlTargetsPrivateNetwork", () => { it.each([ { diff --git a/src/plugin-sdk/ssrf-policy.ts b/src/plugin-sdk/ssrf-policy.ts index 336eba0fac1..06e0aaa11f6 100644 --- a/src/plugin-sdk/ssrf-policy.ts +++ b/src/plugin-sdk/ssrf-policy.ts @@ -9,10 +9,101 @@ import { export { isPrivateIpAddress }; export type { SsrFPolicy }; +export type PrivateNetworkOptInInput = + | boolean + | null + | undefined + | Pick + | { + allowPrivateNetwork?: boolean | null; + dangerouslyAllowPrivateNetwork?: boolean | null; + network?: + | Pick + | null + | undefined; + }; + +function asRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +export function isPrivateNetworkOptInEnabled(input: PrivateNetworkOptInInput): boolean { + if (input === true) { + return true; + } + const record = asRecord(input); + if (!record) { + return false; + } + const network = asRecord(record.network); + return ( + record.allowPrivateNetwork === true || + record.dangerouslyAllowPrivateNetwork === true || + network?.allowPrivateNetwork === true || + network?.dangerouslyAllowPrivateNetwork === true + ); +} + +export function ssrfPolicyFromPrivateNetworkOptIn( + input: PrivateNetworkOptInInput, +): SsrFPolicy | undefined { + return isPrivateNetworkOptInEnabled(input) ? { allowPrivateNetwork: true } : undefined; +} + +export function hasLegacyFlatAllowPrivateNetworkAlias(value: unknown): boolean { + const entry = asRecord(value); + return Boolean(entry && Object.prototype.hasOwnProperty.call(entry, "allowPrivateNetwork")); +} + +export function migrateLegacyFlatAllowPrivateNetworkAlias(params: { + entry: Record; + pathPrefix: string; + changes: string[]; +}): { entry: Record; changed: boolean } { + if (!hasLegacyFlatAllowPrivateNetworkAlias(params.entry)) { + return { entry: params.entry, changed: false }; + } + + const legacyAllowPrivateNetwork = params.entry.allowPrivateNetwork; + const currentNetworkRecord = asRecord(params.entry.network); + const currentNetwork = currentNetworkRecord ? { ...currentNetworkRecord } : {}; + const currentDangerousAllowPrivateNetwork = currentNetwork.dangerouslyAllowPrivateNetwork; + + let resolvedDangerousAllowPrivateNetwork: unknown = currentDangerousAllowPrivateNetwork; + if (typeof currentDangerousAllowPrivateNetwork === "boolean") { + // The canonical key wins when both shapes are present. + resolvedDangerousAllowPrivateNetwork = currentDangerousAllowPrivateNetwork; + } else if (typeof legacyAllowPrivateNetwork === "boolean") { + resolvedDangerousAllowPrivateNetwork = legacyAllowPrivateNetwork; + } else if (currentDangerousAllowPrivateNetwork === undefined) { + resolvedDangerousAllowPrivateNetwork = legacyAllowPrivateNetwork; + } + + delete currentNetwork.dangerouslyAllowPrivateNetwork; + if (resolvedDangerousAllowPrivateNetwork !== undefined) { + currentNetwork.dangerouslyAllowPrivateNetwork = resolvedDangerousAllowPrivateNetwork; + } + + const nextEntry = { ...params.entry }; + delete nextEntry.allowPrivateNetwork; + if (Object.keys(currentNetwork).length > 0) { + nextEntry.network = currentNetwork; + } else { + delete nextEntry.network; + } + + params.changes.push( + `Moved ${params.pathPrefix}.allowPrivateNetwork → ${params.pathPrefix}.network.dangerouslyAllowPrivateNetwork (${String(resolvedDangerousAllowPrivateNetwork)}).`, + ); + return { entry: nextEntry, changed: true }; +} + export function ssrfPolicyFromAllowPrivateNetwork( allowPrivateNetwork: boolean | null | undefined, ): SsrFPolicy | undefined { - return allowPrivateNetwork ? { allowPrivateNetwork: true } : undefined; + return ssrfPolicyFromPrivateNetworkOptIn(allowPrivateNetwork); } export async function assertHttpUrlTargetsPrivateNetwork( diff --git a/src/plugin-sdk/ssrf-runtime.ts b/src/plugin-sdk/ssrf-runtime.ts index bbb2e784181..7b846f99c6e 100644 --- a/src/plugin-sdk/ssrf-runtime.ts +++ b/src/plugin-sdk/ssrf-runtime.ts @@ -15,6 +15,10 @@ export { fetchWithSsrFGuard } from "../infra/net/fetch-guard.js"; export { assertHttpUrlTargetsPrivateNetwork, buildHostnameAllowlistPolicyFromSuffixAllowlist, + hasLegacyFlatAllowPrivateNetworkAlias, + isPrivateNetworkOptInEnabled, + migrateLegacyFlatAllowPrivateNetworkAlias, + ssrfPolicyFromPrivateNetworkOptIn, ssrfPolicyFromAllowPrivateNetwork, } from "./ssrf-policy.js"; export { isPrivateOrLoopbackHost } from "../gateway/net.js";