fix(config): migrate bundled private-network aliases (#60862)

* refactor(plugin-sdk): centralize private-network opt-in semantics

* fix(config): migrate bundled private-network aliases

* fix(config): add bundled private-network doctor adapters

* fix(config): expose bundled channel migration hooks

* fix(config): prefer canonical private-network key

* test(config): refresh rebased private-network outputs
This commit is contained in:
Vincent Koc 2026-04-05 08:49:44 +01:00 committed by GitHub
parent 87b8680ded
commit c863ee1b86
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
73 changed files with 1935 additions and 87 deletions

View File

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

View File

@ -1,3 +1,4 @@
export { normalizeCompatibilityConfig, legacyConfigRules } from "./src/doctor-contract.js";
export {
collectRuntimeConfigAssignments,
secretTargetRegistryEntries,

View File

@ -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,
};
}

View File

@ -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.`);

View File

@ -278,7 +278,9 @@ describe("downloadBlueBubblesAttachment", () => {
bluebubbles: {
serverUrl: "http://localhost:1234",
password: "test",
allowPrivateNetwork: true,
network: {
dangerouslyAllowPrivateNetwork: true,
},
},
},
},

View File

@ -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<ResolvedBlueBubblesAccount, BlueBu
isConfigured: (account) => account.configured,
describeAccount: (account): ChannelAccountSnapshot => describeBlueBubblesAccount(account),
},
doctor: bluebubblesDoctor,
conversationBindings: {
supportsCurrentConversationBinding: true,
createManager: ({ cfg, accountId }) =>
@ -226,7 +229,7 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount, BlueBu
baseUrl: account.baseUrl,
password: account.config.password ?? null,
timeoutMs,
allowPrivateNetwork: account.config.allowPrivateNetwork === true,
allowPrivateNetwork: isPrivateNetworkOptInEnabled(account.config),
}),
resolveAccountSnapshot: ({ account, runtime, probe }) => {
const running = runtime?.running ?? false;

View File

@ -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(),
})

View File

@ -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<string, unknown> {
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.<id>.allowPrivateNetwork is legacy; use channels.bluebubbles.accounts.<id>.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<string, unknown> = { ...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<OpenClawConfig["channels"]>["bluebubbles"],
},
},
changes,
};
}

View File

@ -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,
});
});
});

View File

@ -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<string, unknown> {
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<string, unknown> = { ...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<OpenClawConfig["channels"]>["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.<id>.allowPrivateNetwork is legacy; use channels.bluebubbles.accounts.<id>.network.dangerouslyAllowPrivateNetwork instead (auto-migrated on load).",
match: hasLegacyAllowPrivateNetworkInAccounts,
},
];
export const bluebubblesDoctor: ChannelDoctorAdapter = {
legacyConfigRules: BLUEBUBBLES_LEGACY_CONFIG_RULES,
normalizeCompatibilityConfig: ({ cfg }) => normalizeBlueBubblesCompatibilityConfig(cfg),
};

View File

@ -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;
}
}

View File

@ -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}`);

View File

@ -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<string, BlueBubblesGroupConfig>;
/** Per-action tool gating (default: true for all). */

View File

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

View File

@ -1,3 +1,4 @@
export { normalizeCompatibilityConfig, legacyConfigRules } from "./src/doctor-contract.js";
export {
namedAccountPromotionKeys,
resolveSingleAccountPromotionTarget,

View File

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

View File

@ -157,7 +157,7 @@ const matrixConfigAdapter = createScopedChannelConfigAdapter<
clearBaseFields: [
"name",
"homeserver",
"allowPrivateNetwork",
"network",
"proxy",
"userId",
"accessToken",

View File

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

View File

@ -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<string, unknown> {
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<string, unknown>;
pathPrefix: string;
changes: string[];
}): { rooms: Record<string, unknown>; changed: boolean } {
let changed = false;
const nextRooms: Record<string, unknown> = { ...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.<id>.allowPrivateNetwork is legacy; use channels.matrix.accounts.<id>.network.dangerouslyAllowPrivateNetwork instead (auto-migrated on load).",
match: hasLegacyMatrixAccountPrivateNetworkAliases,
},
{
path: ["channels", "matrix", "groups"],
message:
"channels.matrix.groups.<room>.allow is legacy; use channels.matrix.groups.<room>.enabled instead (auto-migrated on load).",
match: hasLegacyMatrixRoomMapAllowAliases,
},
{
path: ["channels", "matrix", "rooms"],
message:
"channels.matrix.rooms.<room>.allow is legacy; use channels.matrix.rooms.<room>.enabled instead (auto-migrated on load).",
match: hasLegacyMatrixRoomMapAllowAliases,
},
{
path: ["channels", "matrix", "accounts"],
message:
"channels.matrix.accounts.<id>.{groups,rooms}.<room>.allow is legacy; use channels.matrix.accounts.<id>.{groups,rooms}.<room>.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<string, unknown> = 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<string, unknown> = { ...accounts };
for (const [accountId, accountValue] of Object.entries(accounts)) {
const account = isRecord(accountValue) ? accountValue : null;
if (!account) {
continue;
}
let nextAccount: Record<string, unknown> = 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<OpenClawConfig["channels"]>["matrix"],
},
},
changes,
};
}

View File

@ -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).",
]),
);
});
});

View File

@ -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<string, unknown>;
pathPrefix: string;
@ -86,6 +101,14 @@ function normalizeMatrixCompatibilityConfig(cfg: OpenClawConfig): ChannelDoctorC
let updatedMatrix: Record<string, unknown> = 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<string, unknown> = 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.<id>.allowPrivateNetwork is legacy; use channels.matrix.accounts.<id>.network.dangerouslyAllowPrivateNetwork instead (auto-migrated on load).",
match: hasLegacyMatrixAccountPrivateNetworkAliases,
},
{
path: ["channels", "matrix", "groups"],
message:

View File

@ -6,6 +6,7 @@ export {
export { isPrivateOrLoopbackHost } from "./private-network-host.js";
export {
assertHttpUrlTargetsPrivateNetwork,
isPrivateNetworkOptInEnabled,
ssrfPolicyFromAllowPrivateNetwork,
type LookupFn,
type SsrFPolicy,

View File

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

View File

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

View File

@ -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<string, unknown>) }
: {};
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;
}
}

View File

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

View File

@ -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",
});
});

View File

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

View File

@ -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<MatrixConfig, "accounts">;
@ -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). */

View File

@ -1,3 +1,4 @@
export { normalizeCompatibilityConfig, legacyConfigRules } from "./src/doctor-contract.js";
export {
collectRuntimeConfigAssignments,
secretTargetRegistryEntries,

View File

@ -1,3 +1,4 @@
export { normalizeCompatibilityConfig, legacyConfigRules } from "./src/doctor-contract.js";
export {
collectRuntimeConfigAssignments,
secretTargetRegistryEntries,

View File

@ -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<ResolvedMattermostAccount> = create
}),
},
auth: mattermostApprovalAuth,
doctor: {
collectMutableAllowlistWarnings: collectMattermostMutableAllowlistWarnings,
},
doctor: mattermostDoctor,
groups: {
resolveRequireMention: resolveMattermostGroupRequireMention,
},
@ -388,7 +387,7 @@ export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = create
baseUrl,
token,
timeoutMs,
account.config.allowPrivateNetwork === true,
isPrivateNetworkOptInEnabled(account.config),
);
},
resolveAccountSnapshot: ({ account, runtime }) => ({

View File

@ -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,
})

View File

@ -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,
})

View File

@ -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<string, unknown> {
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.<id>.allowPrivateNetwork is legacy; use channels.mattermost.accounts.<id>.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<string, unknown> = { ...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<OpenClawConfig["channels"]>["mattermost"],
},
},
changes,
};
}

View File

@ -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,
});
});
});

View File

@ -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<string, unknown> {
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.<id>.allowPrivateNetwork is legacy; use channels.mattermost.accounts.<id>.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<string, unknown> = { ...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<OpenClawConfig["channels"]>["mattermost"],
},
},
changes,
};
}
export const mattermostDoctor: ChannelDoctorAdapter = {
legacyConfigRules: MATTERMOST_LEGACY_CONFIG_RULES,
normalizeCompatibilityConfig: ({ cfg }) => normalizeMattermostCompatibilityConfig(cfg),
collectMutableAllowlistWarnings: collectMattermostMutableAllowlistWarnings,
};

View File

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

View File

@ -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),
});
}

View File

@ -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.

View File

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

View File

@ -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}`;

View File

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

View File

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

View File

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

View File

@ -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) */

View File

@ -1,3 +1,4 @@
export { normalizeCompatibilityConfig, legacyConfigRules } from "./src/doctor-contract.js";
export {
collectRuntimeConfigAssignments,
secretTargetRegistryEntries,

View File

@ -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<ResolvedNextcloudTalkAccount> =
}),
},
auth: nextcloudTalkApprovalAuth,
doctor: nextcloudTalkDoctor,
groups: {
resolveRequireMention: ({ cfg, accountId, groupId }) => {
const account = resolveNextcloudTalkAccount({ cfg: cfg as CoreConfig, accountId });

View File

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

View File

@ -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<string, unknown> {
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.<id>.allowPrivateNetwork is legacy; use channels.nextcloud-talk.accounts.<id>.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<string, unknown> = { ...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<OpenClawConfig["channels"]>["nextcloud-talk"],
},
},
changes,
};
}

View File

@ -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,
});
});
});

View File

@ -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<string, unknown> {
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<string, unknown> = { ...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<OpenClawConfig["channels"]>["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.<id>.allowPrivateNetwork is legacy; use channels.nextcloud-talk.accounts.<id>.network.dangerouslyAllowPrivateNetwork instead (auto-migrated on load).",
match: hasLegacyAllowPrivateNetworkInAccounts,
},
];
export const nextcloudTalkDoctor: ChannelDoctorAdapter = {
legacyConfigRules: NEXTCLOUD_TALK_LEGACY_CONFIG_RULES,
normalizeCompatibilityConfig: ({ cfg }) => normalizeNextcloudTalkCompatibilityConfig(cfg),
};

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1 @@
export { normalizeCompatibilityConfig, legacyConfigRules } from "./src/doctor-contract.js";

View File

@ -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 } : {}),

View File

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

View File

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

View File

@ -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", () => {

View File

@ -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<string, unknown> {
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.<id>.allowPrivateNetwork is legacy; use channels.tlon.accounts.<id>.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<string, unknown> = { ...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<OpenClawConfig["channels"]>["tlon"],
},
},
changes,
};
}

View File

@ -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,
});
});
});

View File

@ -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<string, unknown> {
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<string, unknown> = { ...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<OpenClawConfig["channels"]>["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.<id>.allowPrivateNetwork is legacy; use channels.tlon.accounts.<id>.network.dangerouslyAllowPrivateNetwork instead (auto-migrated on load).",
match: hasLegacyAllowPrivateNetworkInAccounts,
},
];
export const tlonDoctor: ChannelDoctorAdapter = {
legacyConfigRules: TLON_LEGACY_CONFIG_RULES,
normalizeCompatibilityConfig: ({ cfg }) => normalizeTlonCompatibilityConfig(cfg),
};

View File

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

View File

@ -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[];

View File

@ -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<string, unknown> }) => {
config: Record<string, unknown>;
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);
},
);
});

View File

@ -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).",
]),
);
});
});

View File

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

View File

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

View File

@ -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([
{

View File

@ -9,10 +9,101 @@ import {
export { isPrivateIpAddress };
export type { SsrFPolicy };
export type PrivateNetworkOptInInput =
| boolean
| null
| undefined
| Pick<SsrFPolicy, "allowPrivateNetwork" | "dangerouslyAllowPrivateNetwork">
| {
allowPrivateNetwork?: boolean | null;
dangerouslyAllowPrivateNetwork?: boolean | null;
network?:
| Pick<SsrFPolicy, "allowPrivateNetwork" | "dangerouslyAllowPrivateNetwork">
| null
| undefined;
};
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: 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<string, unknown>;
pathPrefix: string;
changes: string[];
}): { entry: Record<string, unknown>; 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(

View File

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