fix(plugin-sdk): prefer canonical private-network opt-in

This commit is contained in:
Vincent Koc
2026-04-05 11:42:35 +01:00
parent 0f58cef75e
commit 63db3443f1
25 changed files with 142 additions and 42 deletions

View File

@@ -84,6 +84,8 @@ export type ChannelSetupInput = {
audience?: string;
useEnv?: boolean;
homeserver?: string;
dangerouslyAllowPrivateNetwork?: boolean;
/** Compatibility alias for legacy setup callers; prefer dangerouslyAllowPrivateNetwork. */
allowPrivateNetwork?: boolean;
proxy?: string;
userId?: string;

View File

@@ -8,6 +8,7 @@ import {
isHttpsUrlAllowedByHostnameSuffixAllowlist,
migrateLegacyFlatAllowPrivateNetworkAlias,
normalizeHostnameSuffixAllowlist,
ssrfPolicyFromDangerouslyAllowPrivateNetwork,
ssrfPolicyFromAllowPrivateNetwork,
ssrfPolicyFromPrivateNetworkOptIn,
} from "./ssrf-policy.js";
@@ -21,6 +22,28 @@ function createLookupFn(addresses: Array<{ address: string; family: number }>):
}) as unknown as LookupFn;
}
describe("ssrfPolicyFromDangerouslyAllowPrivateNetwork", () => {
it.each([
{
name: "returns undefined for missing input",
input: undefined,
expected: undefined,
},
{
name: "returns undefined when private-network access is disabled",
input: false,
expected: undefined,
},
{
name: "returns an explicit allow-private-network policy when enabled",
input: true,
expected: { allowPrivateNetwork: true },
},
])("$name", ({ input, expected }) => {
expect(ssrfPolicyFromDangerouslyAllowPrivateNetwork(input)).toEqual(expected);
});
});
describe("ssrfPolicyFromAllowPrivateNetwork", () => {
it.each([
{
@@ -180,7 +203,7 @@ describe("assertHttpUrlTargetsPrivateNetwork", () => {
name: "allows https targets without private-network checks",
url: "https://matrix.example.org",
policy: {
allowPrivateNetwork: false,
dangerouslyAllowPrivateNetwork: false,
},
outcome: "resolve",
},
@@ -188,7 +211,7 @@ describe("assertHttpUrlTargetsPrivateNetwork", () => {
name: "allows internal DNS names only when they resolve exclusively to private IPs",
url: "http://matrix-synapse:8008",
policy: {
allowPrivateNetwork: true,
dangerouslyAllowPrivateNetwork: true,
lookupFn: createLookupFn([{ address: "10.0.0.5", family: 4 }]),
},
outcome: "resolve",
@@ -197,7 +220,7 @@ describe("assertHttpUrlTargetsPrivateNetwork", () => {
name: "rejects cleartext public hosts even when private-network access is enabled",
url: "http://matrix.example.org:8008",
policy: {
allowPrivateNetwork: true,
dangerouslyAllowPrivateNetwork: true,
lookupFn: createLookupFn([{ address: "93.184.216.34", family: 4 }]),
errorMessage:
"Matrix homeserver must use https:// unless it targets a private or loopback host",
@@ -214,6 +237,16 @@ describe("assertHttpUrlTargetsPrivateNetwork", () => {
}
await expect(result).resolves.toBeUndefined();
});
it("prefers the canonical flag when both canonical and legacy flags are present", async () => {
await expect(
assertHttpUrlTargetsPrivateNetwork("http://matrix-synapse:8008", {
dangerouslyAllowPrivateNetwork: false,
allowPrivateNetwork: true,
lookupFn: createLookupFn([{ address: "10.0.0.5", family: 4 }]),
}),
).rejects.toThrow("HTTP URL must target a trusted private/internal host");
});
});
describe("normalizeHostnameSuffixAllowlist", () => {

View File

@@ -15,8 +15,9 @@ export type PrivateNetworkOptInInput =
| undefined
| Pick<SsrFPolicy, "allowPrivateNetwork" | "dangerouslyAllowPrivateNetwork">
| {
allowPrivateNetwork?: boolean | null;
dangerouslyAllowPrivateNetwork?: boolean | null;
/** Compatibility alias for legacy callers; prefer dangerouslyAllowPrivateNetwork. */
allowPrivateNetwork?: boolean | null;
network?:
| Pick<SsrFPolicy, "allowPrivateNetwork" | "dangerouslyAllowPrivateNetwork">
| null
@@ -52,6 +53,12 @@ export function ssrfPolicyFromPrivateNetworkOptIn(
return isPrivateNetworkOptInEnabled(input) ? { allowPrivateNetwork: true } : undefined;
}
export function ssrfPolicyFromDangerouslyAllowPrivateNetwork(
dangerouslyAllowPrivateNetwork: boolean | null | undefined,
): SsrFPolicy | undefined {
return ssrfPolicyFromPrivateNetworkOptIn(dangerouslyAllowPrivateNetwork);
}
export function hasLegacyFlatAllowPrivateNetworkAlias(value: unknown): boolean {
const entry = asRecord(value);
return Boolean(entry && Object.prototype.hasOwnProperty.call(entry, "allowPrivateNetwork"));
@@ -103,12 +110,13 @@ export function migrateLegacyFlatAllowPrivateNetworkAlias(params: {
export function ssrfPolicyFromAllowPrivateNetwork(
allowPrivateNetwork: boolean | null | undefined,
): SsrFPolicy | undefined {
return ssrfPolicyFromPrivateNetworkOptIn(allowPrivateNetwork);
return ssrfPolicyFromDangerouslyAllowPrivateNetwork(allowPrivateNetwork);
}
export async function assertHttpUrlTargetsPrivateNetwork(
url: string,
params: {
dangerouslyAllowPrivateNetwork?: boolean | null;
allowPrivateNetwork?: boolean | null;
lookupFn?: LookupFn;
errorMessage?: string;
@@ -131,15 +139,20 @@ export async function assertHttpUrlTargetsPrivateNetwork(
return;
}
if (params.allowPrivateNetwork !== true) {
const allowPrivateNetwork =
typeof params.dangerouslyAllowPrivateNetwork === "boolean"
? params.dangerouslyAllowPrivateNetwork
: params.allowPrivateNetwork;
if (allowPrivateNetwork !== true) {
throw new Error(errorMessage);
}
// allowPrivateNetwork is an opt-in for trusted private/internal targets, not
// a blanket exemption for cleartext public internet hosts.
// Private-network opt-in is for trusted private/internal targets, not a
// blanket exemption for cleartext public internet hosts.
const pinned = await resolvePinnedHostnameWithPolicy(hostname, {
lookupFn: params.lookupFn,
policy: ssrfPolicyFromAllowPrivateNetwork(true),
policy: ssrfPolicyFromDangerouslyAllowPrivateNetwork(true),
});
if (!pinned.addresses.every((address) => isPrivateIpAddress(address))) {
throw new Error(errorMessage);

View File

@@ -18,6 +18,7 @@ export {
hasLegacyFlatAllowPrivateNetworkAlias,
isPrivateNetworkOptInEnabled,
migrateLegacyFlatAllowPrivateNetworkAlias,
ssrfPolicyFromDangerouslyAllowPrivateNetwork,
ssrfPolicyFromPrivateNetworkOptIn,
ssrfPolicyFromAllowPrivateNetwork,
} from "./ssrf-policy.js";

View File

@@ -5,4 +5,5 @@ export { definePluginEntry } from "./plugin-entry.js";
export type { OpenClawConfig } from "../config/config.js";
export type { OpenClawPluginApi } from "../plugins/types.js";
export { fetchWithSsrFGuard } from "../infra/net/fetch-guard.js";
export { ssrfPolicyFromDangerouslyAllowPrivateNetwork } from "./ssrf-policy.js";
export { ssrfPolicyFromAllowPrivateNetwork } from "./ssrf-policy.js";

View File

@@ -56,7 +56,7 @@ const RUNTIME_API_EXPORT_GUARDS: Record<string, readonly string[]> = {
'export * from "./src/account-selection.js";',
'export * from "./src/env-vars.js";',
'export * from "./src/storage-paths.js";',
'export { assertHttpUrlTargetsPrivateNetwork, closeDispatcher, createPinnedDispatcher, resolvePinnedHostnameWithPolicy, ssrfPolicyFromAllowPrivateNetwork, type LookupFn, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";',
'export { assertHttpUrlTargetsPrivateNetwork, closeDispatcher, createPinnedDispatcher, resolvePinnedHostnameWithPolicy, ssrfPolicyFromDangerouslyAllowPrivateNetwork, ssrfPolicyFromAllowPrivateNetwork, type LookupFn, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";',
'export { setMatrixThreadBindingIdleTimeoutBySessionKey, setMatrixThreadBindingMaxAgeBySessionKey } from "./src/matrix/thread-bindings-shared.js";',
'export { setMatrixRuntime } from "./src/runtime.js";',
'export { writeJsonFileAtomically } from "openclaw/plugin-sdk/json-store";',

View File

@@ -662,6 +662,7 @@ describe("plugin-sdk subpath exports", () => {
"resolvePinnedHostnameWithPolicy",
"formatErrorMessage",
"assertHttpUrlTargetsPrivateNetwork",
"ssrfPolicyFromDangerouslyAllowPrivateNetwork",
"ssrfPolicyFromAllowPrivateNetwork",
]);