import { isBlockedHostnameOrIp, isPrivateIpAddress, resolvePinnedHostnameWithPolicy, type LookupFn, type SsrFPolicy, } from "../infra/net/ssrf.js"; export { isPrivateIpAddress }; export type { SsrFPolicy }; export type PrivateNetworkOptInInput = | boolean | null | undefined | Pick | { dangerouslyAllowPrivateNetwork?: boolean | null; /** Compatibility alias for legacy callers; prefer dangerouslyAllowPrivateNetwork. */ allowPrivateNetwork?: boolean | null; network?: | Pick | null | undefined; }; function asRecord(value: unknown): Record | null { return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : 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 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")); } export function migrateLegacyFlatAllowPrivateNetworkAlias(params: { entry: Record; pathPrefix: string; changes: string[]; }): { entry: Record; 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 ssrfPolicyFromDangerouslyAllowPrivateNetwork(allowPrivateNetwork); } export async function assertHttpUrlTargetsPrivateNetwork( url: string, params: { dangerouslyAllowPrivateNetwork?: boolean | null; allowPrivateNetwork?: boolean | null; lookupFn?: LookupFn; errorMessage?: string; } = {}, ): Promise { 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; } 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 = 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(); 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) }; }