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
73 changed files with 1935 additions and 87 deletions

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