openclaw/src/plugin-sdk/ssrf-policy.ts
2026-03-19 23:24:50 -04:00

140 lines
3.9 KiB
TypeScript

import {
isBlockedHostnameOrIp,
isPrivateIpAddress,
resolvePinnedHostnameWithPolicy,
type LookupFn,
type SsrFPolicy,
} from "../infra/net/ssrf.js";
export function ssrfPolicyFromAllowPrivateNetwork(
allowPrivateNetwork: boolean | null | undefined,
): SsrFPolicy | undefined {
return allowPrivateNetwork ? { allowPrivateNetwork: true } : undefined;
}
export async function assertHttpUrlTargetsPrivateNetwork(
url: string,
params: {
allowPrivateNetwork?: boolean | null;
lookupFn?: LookupFn;
errorMessage?: string;
} = {},
): Promise<void> {
const parsed = new URL(url);
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;
}
if (params.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.
const pinned = await resolvePinnedHostnameWithPolicy(hostname, {
lookupFn: params.lookupFn,
policy: ssrfPolicyFromAllowPrivateNetwork(true),
});
if (!pinned.addresses.every((address) => isPrivateIpAddress(address))) {
throw new Error(errorMessage);
}
}
function normalizeHostnameSuffix(value: string): string {
const trimmed = value.trim().toLowerCase();
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 = hostname.toLowerCase();
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 = source.map(normalizeHostnameSuffix).filter(Boolean);
if (normalized.includes("*")) {
return ["*"];
}
return Array.from(new Set(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) };
}