Files
openclaw/src/plugin-sdk/ssrf-policy.ts
T
Wynne668 e7da4aef0a fix(plugin-sdk): guard assertHttpUrlTargetsPrivateNetwork against malformed URLs (#109729)
* fix(plugin-sdk): guard assertHttpUrlTargetsPrivateNetwork against malformed URLs

Wrap new URL() in try/catch so public SSRF helper callers get a typed
Invalid URL error instead of an uncaught TypeError from the URL parser.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(plugin-sdk): use stable Invalid URL error for malformed SSRF input

Stop echoing caller endpoint strings in parse failures and add coverage
that credential-bearing malformed URLs are not reflected in errors.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(plugin-sdk): drop native URL error cause from SSRF guard

Do not attach the native ERR_INVALID_URL as cause when wrapping
malformed URL parser failures. Node's URL parser retains the
rejected URL in its structured input property, so preserving
it as cause leaves credential-bearing endpoint strings reachable
to callers that inspect or serialize the complete error graph.

Extend the credential-reflection regression test to assert the
cause is absent and the full error serialization is clean.

* fix(plugin-sdk): preserve TypeError contract for malformed URL failures

Throw sanitized TypeError("Invalid URL") instead of plain Error to
preserve the public plugin SDK error contract. Callers may rely on
instanceof TypeError to distinguish malformed endpoint input from
other error classes.

* chore: retry CI (pre-existing child-process flake unrelated to ssrf-policy change)

* fix(plugin-sdk): preserve ERR_INVALID_URL code in sanitized TypeError

Add code: "ERR_INVALID_URL" to the sanitized TypeError so callers
that classify malformed URLs through err.code continue to work.
Still strips the native input and cause to prevent credential reflection.

* test(plugin-sdk): trim malformed URL guard coverage

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-07-20 21:58:05 -07:00

362 lines
12 KiB
TypeScript

// SSRF policy helpers enforce network target safety for plugin HTTP requests.
import { asNullableRecord } from "../../packages/normalization-core/src/record-coerce.js";
import { normalizeLowercaseStringOrEmpty } from "../../packages/normalization-core/src/string-coerce.js";
import { normalizeUniqueStringEntries } from "../../packages/normalization-core/src/string-normalization.js";
import {
isBlockedHostnameOrIp,
isPrivateIpAddress,
mergeSsrFPolicies,
resolvePinnedHostnameWithPolicy,
type LookupFn,
type SsrFPolicy,
} from "../infra/net/ssrf.js";
import type {
ChannelDoctorConfigMutation,
ChannelDoctorLegacyConfigRule,
} from "./channel-contract.js";
import type { OpenClawConfig } from "./config-runtime.js";
export { isPrivateIpAddress, mergeSsrFPolicies };
export type { SsrFPolicy };
/** Accepted channel config shapes that opt into private-network HTTP targets. */
export type PrivateNetworkOptInInput =
| boolean
| null
| undefined
| Pick<SsrFPolicy, "allowPrivateNetwork" | "dangerouslyAllowPrivateNetwork">
| {
/** Canonical explicit opt-in for private/internal network targets. */
dangerouslyAllowPrivateNetwork?: boolean | null;
/** @deprecated Compatibility alias; prefer dangerouslyAllowPrivateNetwork. */
allowPrivateNetwork?: boolean | null;
/** Nested channel config shape used by current plugin network settings. */
network?:
| Pick<SsrFPolicy, "allowPrivateNetwork" | "dangerouslyAllowPrivateNetwork">
| null
| undefined;
};
/** Reads current and legacy private-network opt-in shapes from channel config. */
export function isPrivateNetworkOptInEnabled(input: PrivateNetworkOptInInput): boolean {
if (input === true) {
return true;
}
const record = asNullableRecord(input);
if (!record) {
return false;
}
const network = asNullableRecord(record.network);
return (
record.allowPrivateNetwork === true ||
record.dangerouslyAllowPrivateNetwork === true ||
network?.allowPrivateNetwork === true ||
network?.dangerouslyAllowPrivateNetwork === true
);
}
/** Converts channel private-network opt-in config into the shared SSRF policy shape. */
export function ssrfPolicyFromPrivateNetworkOptIn(
input: PrivateNetworkOptInInput,
): SsrFPolicy | undefined {
return isPrivateNetworkOptInEnabled(input) ? { allowPrivateNetwork: true } : undefined;
}
/** Compatibility wrapper for callers that already use the canonical dangerous flag name. */
export function ssrfPolicyFromDangerouslyAllowPrivateNetwork(
dangerouslyAllowPrivateNetwork: boolean | null | undefined,
): SsrFPolicy | undefined {
return ssrfPolicyFromPrivateNetworkOptIn(dangerouslyAllowPrivateNetwork);
}
/** Detects the retired flat `allowPrivateNetwork` key before doctor migration. */
export function hasLegacyFlatAllowPrivateNetworkAlias(value: unknown): boolean {
const entry = asNullableRecord(value);
return Boolean(entry && Object.hasOwn(entry, "allowPrivateNetwork"));
}
/** Moves flat private-network config into `network.dangerouslyAllowPrivateNetwork`. */
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 = asNullableRecord(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 };
}
function hasLegacyAllowPrivateNetworkInAccounts(value: unknown): boolean {
const accounts = asNullableRecord(value);
return Boolean(
accounts &&
Object.values(accounts).some((account) =>
hasLegacyFlatAllowPrivateNetworkAlias(asNullableRecord(account) ?? {}),
),
);
}
/** Build doctor rules that migrate legacy private-network aliases for one channel config. */
export function createLegacyPrivateNetworkDoctorContract(params: { channelKey: string }): {
legacyConfigRules: ChannelDoctorLegacyConfigRule[];
normalizeCompatibilityConfig: (params: { cfg: OpenClawConfig }) => ChannelDoctorConfigMutation;
} {
const pathPrefix = `channels.${params.channelKey}`;
return {
legacyConfigRules: [
{
path: ["channels", params.channelKey],
message: `${pathPrefix}.allowPrivateNetwork is legacy; use ${pathPrefix}.network.dangerouslyAllowPrivateNetwork instead. Run "openclaw doctor --fix".`,
match: (value) => hasLegacyFlatAllowPrivateNetworkAlias(asNullableRecord(value) ?? {}),
},
{
path: ["channels", params.channelKey, "accounts"],
message: `${pathPrefix}.accounts.<id>.allowPrivateNetwork is legacy; use ${pathPrefix}.accounts.<id>.network.dangerouslyAllowPrivateNetwork instead. Run "openclaw doctor --fix".`,
match: hasLegacyAllowPrivateNetworkInAccounts,
},
],
normalizeCompatibilityConfig: ({ cfg }) => {
const channels = asNullableRecord(cfg.channels);
const channelEntry = asNullableRecord(channels?.[params.channelKey]);
if (!channelEntry) {
return { config: cfg, changes: [] };
}
const changes: string[] = [];
let updatedChannel = channelEntry;
let changed = false;
const topLevel = migrateLegacyFlatAllowPrivateNetworkAlias({
entry: updatedChannel,
pathPrefix,
changes,
});
updatedChannel = topLevel.entry;
changed = changed || topLevel.changed;
const accounts = asNullableRecord(updatedChannel.accounts);
if (accounts) {
let accountsChanged = false;
const nextAccounts: Record<string, unknown> = { ...accounts };
for (const [accountId, accountValue] of Object.entries(accounts)) {
const account = asNullableRecord(accountValue);
if (!account) {
continue;
}
const migrated = migrateLegacyFlatAllowPrivateNetworkAlias({
entry: account,
pathPrefix: `${pathPrefix}.accounts.${accountId}`,
changes,
});
if (!migrated.changed) {
continue;
}
nextAccounts[accountId] = migrated.entry;
accountsChanged = true;
}
if (accountsChanged) {
updatedChannel = { ...updatedChannel, accounts: nextAccounts };
changed = true;
}
}
if (!changed) {
return { config: cfg, changes: [] };
}
return {
config: {
...cfg,
channels: {
...cfg.channels,
[params.channelKey]: updatedChannel,
} as OpenClawConfig["channels"],
},
changes,
};
},
};
}
/** @deprecated Use `ssrfPolicyFromDangerouslyAllowPrivateNetwork`. */
export function ssrfPolicyFromAllowPrivateNetwork(
allowPrivateNetwork: boolean | null | undefined,
): SsrFPolicy | undefined {
return ssrfPolicyFromDangerouslyAllowPrivateNetwork(allowPrivateNetwork);
}
/** Allows cleartext HTTP only when the target is loopback/private or DNS-pins to private IPs. */
export async function assertHttpUrlTargetsPrivateNetwork(
url: string,
params: {
dangerouslyAllowPrivateNetwork?: boolean | null;
allowPrivateNetwork?: boolean | null;
lookupFn?: LookupFn;
errorMessage?: string;
} = {},
): Promise<void> {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
// URL parser errors retain rejected input. Keep only stable classification.
const err = new TypeError("Invalid URL") as TypeError & { code: string };
err.code = "ERR_INVALID_URL";
throw err;
}
if (parsed.protocol !== "http:") {
return;
}
const errorMessage =
params.errorMessage ?? "HTTP URL must target a trusted private/internal host";
const { hostname } = parsed;
if (!hostname) {
throw new Error(errorMessage);
}
// Literal loopback/private hosts can stay local without DNS.
if (isBlockedHostnameOrIp(hostname)) {
return;
}
const allowPrivateNetwork =
typeof params.dangerouslyAllowPrivateNetwork === "boolean"
? params.dangerouslyAllowPrivateNetwork
: params.allowPrivateNetwork;
if (allowPrivateNetwork !== true) {
throw new Error(errorMessage);
}
// 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: ssrfPolicyFromDangerouslyAllowPrivateNetwork(true),
});
if (!pinned.addresses.every((address) => isPrivateIpAddress(address))) {
throw new Error(errorMessage);
}
}
function normalizeHostnameSuffix(value: string): string {
const trimmed = normalizeLowercaseStringOrEmpty(value);
if (!trimmed) {
return "";
}
if (trimmed === "*" || trimmed === "*.") {
return "*";
}
const withoutWildcard = trimmed.replace(/^\*\.?/, "");
const withoutLeadingDot = withoutWildcard.replace(/^\.+/, "");
return withoutLeadingDot.replace(/\.+$/, "");
}
function isHostnameAllowedBySuffixAllowlist(
hostname: string,
allowlist: readonly string[],
): boolean {
if (allowlist.includes("*")) {
return true;
}
const normalized = normalizeLowercaseStringOrEmpty(hostname);
return allowlist.some((entry) => normalized === entry || normalized.endsWith(`.${entry}`));
}
/** Normalize suffix-style host allowlists into lowercase canonical entries with wildcard collapse. */
export function normalizeHostnameSuffixAllowlist(
input?: readonly string[],
defaults?: readonly string[],
): string[] {
const source = input && input.length > 0 ? input : defaults;
if (!source || source.length === 0) {
return [];
}
const normalized = normalizeUniqueStringEntries(source.map(normalizeHostnameSuffix));
if (normalized.includes("*")) {
// `*` is an explicit opt-out from hostname suffix restrictions.
return ["*"];
}
return normalized;
}
/** Check whether a URL is HTTPS and its hostname matches the normalized suffix allowlist. */
export function isHttpsUrlAllowedByHostnameSuffixAllowlist(
url: string,
allowlist: readonly string[],
): boolean {
try {
const parsed = new URL(url);
if (parsed.protocol !== "https:") {
return false;
}
return isHostnameAllowedBySuffixAllowlist(parsed.hostname, allowlist);
} catch {
return false;
}
}
/**
* Converts suffix-style host allowlists (for example "example.com") into SSRF
* hostname allowlist patterns used by the shared fetch guard.
*
* Suffix semantics:
* - "example.com" allows "example.com" and "*.example.com"
* - "*" disables hostname allowlist restrictions
*/
export function buildHostnameAllowlistPolicyFromSuffixAllowlist(
allowHosts?: readonly string[],
): SsrFPolicy | undefined {
const normalizedAllowHosts = normalizeHostnameSuffixAllowlist(allowHosts);
if (normalizedAllowHosts.length === 0) {
return undefined;
}
const patterns = new Set<string>();
for (const normalized of normalizedAllowHosts) {
if (normalized === "*") {
return undefined;
}
patterns.add(normalized);
patterns.add(`*.${normalized}`);
}
if (patterns.size === 0) {
return undefined;
}
return { hostnameAllowlist: Array.from(patterns) };
}