| import { lookup as dnsLookupCb, type LookupAddress } from "node:dns"; |
| import { lookup as dnsLookup } from "node:dns/promises"; |
| import { Agent, EnvHttpProxyAgent, ProxyAgent, type Dispatcher } from "undici"; |
| import { |
| extractEmbeddedIpv4FromIpv6, |
| isBlockedSpecialUseIpv4Address, |
| isBlockedSpecialUseIpv6Address, |
| isCanonicalDottedDecimalIPv4, |
| type Ipv4SpecialUseBlockOptions, |
| isIpv4Address, |
| isLegacyIpv4Literal, |
| parseCanonicalIpAddress, |
| parseLooseIpAddress, |
| } from "../../shared/net/ip.js"; |
| import { normalizeHostname } from "./hostname.js"; |
|
|
| type LookupCallback = ( |
| err: NodeJS.ErrnoException | null, |
| address: string | LookupAddress[], |
| family?: number, |
| ) => void; |
|
|
| export class SsrFBlockedError extends Error { |
| constructor(message: string) { |
| super(message); |
| this.name = "SsrFBlockedError"; |
| } |
| } |
|
|
| export type LookupFn = typeof dnsLookup; |
|
|
| export type SsrFPolicy = { |
| allowPrivateNetwork?: boolean; |
| dangerouslyAllowPrivateNetwork?: boolean; |
| allowRfc2544BenchmarkRange?: boolean; |
| allowedHostnames?: string[]; |
| hostnameAllowlist?: string[]; |
| }; |
|
|
| const BLOCKED_HOSTNAMES = new Set([ |
| "localhost", |
| "localhost.localdomain", |
| "metadata.google.internal", |
| ]); |
|
|
| function normalizeHostnameSet(values?: string[]): Set<string> { |
| if (!values || values.length === 0) { |
| return new Set<string>(); |
| } |
| return new Set(values.map((value) => normalizeHostname(value)).filter(Boolean)); |
| } |
|
|
| function normalizeHostnameAllowlist(values?: string[]): string[] { |
| if (!values || values.length === 0) { |
| return []; |
| } |
| return Array.from( |
| new Set( |
| values |
| .map((value) => normalizeHostname(value)) |
| .filter((value) => value !== "*" && value !== "*." && value.length > 0), |
| ), |
| ); |
| } |
|
|
| export function isPrivateNetworkAllowedByPolicy(policy?: SsrFPolicy): boolean { |
| return policy?.dangerouslyAllowPrivateNetwork === true || policy?.allowPrivateNetwork === true; |
| } |
|
|
| function resolveIpv4SpecialUseBlockOptions(policy?: SsrFPolicy): Ipv4SpecialUseBlockOptions { |
| return { |
| allowRfc2544BenchmarkRange: policy?.allowRfc2544BenchmarkRange === true, |
| }; |
| } |
|
|
| function isHostnameAllowedByPattern(hostname: string, pattern: string): boolean { |
| if (pattern.startsWith("*.")) { |
| const suffix = pattern.slice(2); |
| if (!suffix || hostname === suffix) { |
| return false; |
| } |
| return hostname.endsWith(`.${suffix}`); |
| } |
| return hostname === pattern; |
| } |
|
|
| function matchesHostnameAllowlist(hostname: string, allowlist: string[]): boolean { |
| if (allowlist.length === 0) { |
| return true; |
| } |
| return allowlist.some((pattern) => isHostnameAllowedByPattern(hostname, pattern)); |
| } |
|
|
| function looksLikeUnsupportedIpv4Literal(address: string): boolean { |
| const parts = address.split("."); |
| if (parts.length === 0 || parts.length > 4) { |
| return false; |
| } |
| if (parts.some((part) => part.length === 0)) { |
| return true; |
| } |
| |
| |
| return parts.every((part) => /^[0-9]+$/.test(part) || /^0x/i.test(part)); |
| } |
|
|
| |
| export function isPrivateIpAddress(address: string, policy?: SsrFPolicy): boolean { |
| let normalized = address.trim().toLowerCase(); |
| if (normalized.startsWith("[") && normalized.endsWith("]")) { |
| normalized = normalized.slice(1, -1); |
| } |
| if (!normalized) { |
| return false; |
| } |
| const blockOptions = resolveIpv4SpecialUseBlockOptions(policy); |
|
|
| const strictIp = parseCanonicalIpAddress(normalized); |
| if (strictIp) { |
| if (isIpv4Address(strictIp)) { |
| return isBlockedSpecialUseIpv4Address(strictIp, blockOptions); |
| } |
| if (isBlockedSpecialUseIpv6Address(strictIp)) { |
| return true; |
| } |
| const embeddedIpv4 = extractEmbeddedIpv4FromIpv6(strictIp); |
| if (embeddedIpv4) { |
| return isBlockedSpecialUseIpv4Address(embeddedIpv4, blockOptions); |
| } |
| return false; |
| } |
|
|
| |
| if (normalized.includes(":") && !parseLooseIpAddress(normalized)) { |
| return true; |
| } |
|
|
| if (!isCanonicalDottedDecimalIPv4(normalized) && isLegacyIpv4Literal(normalized)) { |
| return true; |
| } |
| if (looksLikeUnsupportedIpv4Literal(normalized)) { |
| return true; |
| } |
| return false; |
| } |
|
|
| export function isBlockedHostname(hostname: string): boolean { |
| const normalized = normalizeHostname(hostname); |
| if (!normalized) { |
| return false; |
| } |
| return isBlockedHostnameNormalized(normalized); |
| } |
|
|
| function isBlockedHostnameNormalized(normalized: string): boolean { |
| if (BLOCKED_HOSTNAMES.has(normalized)) { |
| return true; |
| } |
| return ( |
| normalized.endsWith(".localhost") || |
| normalized.endsWith(".local") || |
| normalized.endsWith(".internal") |
| ); |
| } |
|
|
| export function isBlockedHostnameOrIp(hostname: string, policy?: SsrFPolicy): boolean { |
| const normalized = normalizeHostname(hostname); |
| if (!normalized) { |
| return false; |
| } |
| return isBlockedHostnameNormalized(normalized) || isPrivateIpAddress(normalized, policy); |
| } |
|
|
| const BLOCKED_HOST_OR_IP_MESSAGE = "Blocked hostname or private/internal/special-use IP address"; |
| const BLOCKED_RESOLVED_IP_MESSAGE = "Blocked: resolves to private/internal/special-use IP address"; |
|
|
| function assertAllowedHostOrIpOrThrow(hostnameOrIp: string, policy?: SsrFPolicy): void { |
| if (isBlockedHostnameOrIp(hostnameOrIp, policy)) { |
| throw new SsrFBlockedError(BLOCKED_HOST_OR_IP_MESSAGE); |
| } |
| } |
|
|
| function assertAllowedResolvedAddressesOrThrow( |
| results: readonly LookupAddress[], |
| policy?: SsrFPolicy, |
| ): void { |
| for (const entry of results) { |
| |
| if (isBlockedHostnameOrIp(entry.address, policy)) { |
| throw new SsrFBlockedError(BLOCKED_RESOLVED_IP_MESSAGE); |
| } |
| } |
| } |
|
|
| export function createPinnedLookup(params: { |
| hostname: string; |
| addresses: string[]; |
| fallback?: typeof dnsLookupCb; |
| }): typeof dnsLookupCb { |
| const normalizedHost = normalizeHostname(params.hostname); |
| const fallback = params.fallback ?? dnsLookupCb; |
| const fallbackLookup = fallback as unknown as ( |
| hostname: string, |
| callback: LookupCallback, |
| ) => void; |
| const fallbackWithOptions = fallback as unknown as ( |
| hostname: string, |
| options: unknown, |
| callback: LookupCallback, |
| ) => void; |
| const records = params.addresses.map((address) => ({ |
| address, |
| family: address.includes(":") ? 6 : 4, |
| })); |
| let index = 0; |
|
|
| return ((host: string, options?: unknown, callback?: unknown) => { |
| const cb: LookupCallback = |
| typeof options === "function" ? (options as LookupCallback) : (callback as LookupCallback); |
| if (!cb) { |
| return; |
| } |
| const normalized = normalizeHostname(host); |
| if (!normalized || normalized !== normalizedHost) { |
| if (typeof options === "function" || options === undefined) { |
| return fallbackLookup(host, cb); |
| } |
| return fallbackWithOptions(host, options, cb); |
| } |
|
|
| const opts = |
| typeof options === "object" && options !== null |
| ? (options as { all?: boolean; family?: number }) |
| : {}; |
| const requestedFamily = |
| typeof options === "number" ? options : typeof opts.family === "number" ? opts.family : 0; |
| const candidates = |
| requestedFamily === 4 || requestedFamily === 6 |
| ? records.filter((entry) => entry.family === requestedFamily) |
| : records; |
| const usable = candidates.length > 0 ? candidates : records; |
| if (opts.all) { |
| cb(null, usable as LookupAddress[]); |
| return; |
| } |
| const chosen = usable[index % usable.length]; |
| index += 1; |
| cb(null, chosen.address, chosen.family); |
| }) as typeof dnsLookupCb; |
| } |
|
|
| export type PinnedHostname = { |
| hostname: string; |
| addresses: string[]; |
| lookup: typeof dnsLookupCb; |
| }; |
|
|
| export type PinnedDispatcherPolicy = |
| | { |
| mode: "direct"; |
| connect?: Record<string, unknown>; |
| } |
| | { |
| mode: "env-proxy"; |
| connect?: Record<string, unknown>; |
| proxyTls?: Record<string, unknown>; |
| } |
| | { |
| mode: "explicit-proxy"; |
| proxyUrl: string; |
| proxyTls?: Record<string, unknown>; |
| }; |
|
|
| function dedupeAndPreferIpv4(results: readonly LookupAddress[]): string[] { |
| const seen = new Set<string>(); |
| const ipv4: string[] = []; |
| const otherFamilies: string[] = []; |
| for (const entry of results) { |
| if (seen.has(entry.address)) { |
| continue; |
| } |
| seen.add(entry.address); |
| if (entry.family === 4) { |
| ipv4.push(entry.address); |
| continue; |
| } |
| otherFamilies.push(entry.address); |
| } |
| return [...ipv4, ...otherFamilies]; |
| } |
|
|
| export async function resolvePinnedHostnameWithPolicy( |
| hostname: string, |
| params: { lookupFn?: LookupFn; policy?: SsrFPolicy } = {}, |
| ): Promise<PinnedHostname> { |
| const normalized = normalizeHostname(hostname); |
| if (!normalized) { |
| throw new Error("Invalid hostname"); |
| } |
|
|
| const allowPrivateNetwork = isPrivateNetworkAllowedByPolicy(params.policy); |
| const allowedHostnames = normalizeHostnameSet(params.policy?.allowedHostnames); |
| const hostnameAllowlist = normalizeHostnameAllowlist(params.policy?.hostnameAllowlist); |
| const isExplicitAllowed = allowedHostnames.has(normalized); |
| const skipPrivateNetworkChecks = allowPrivateNetwork || isExplicitAllowed; |
|
|
| if (!matchesHostnameAllowlist(normalized, hostnameAllowlist)) { |
| throw new SsrFBlockedError(`Blocked hostname (not in allowlist): ${hostname}`); |
| } |
|
|
| if (!skipPrivateNetworkChecks) { |
| |
| assertAllowedHostOrIpOrThrow(normalized, params.policy); |
| } |
|
|
| const lookupFn = params.lookupFn ?? dnsLookup; |
| const results = await lookupFn(normalized, { all: true }); |
| if (results.length === 0) { |
| throw new Error(`Unable to resolve hostname: ${hostname}`); |
| } |
|
|
| if (!skipPrivateNetworkChecks) { |
| |
| assertAllowedResolvedAddressesOrThrow(results, params.policy); |
| } |
|
|
| |
| |
| const addresses = dedupeAndPreferIpv4(results); |
| if (addresses.length === 0) { |
| throw new Error(`Unable to resolve hostname: ${hostname}`); |
| } |
|
|
| return { |
| hostname: normalized, |
| addresses, |
| lookup: createPinnedLookup({ hostname: normalized, addresses }), |
| }; |
| } |
|
|
| export async function resolvePinnedHostname( |
| hostname: string, |
| lookupFn: LookupFn = dnsLookup, |
| ): Promise<PinnedHostname> { |
| return await resolvePinnedHostnameWithPolicy(hostname, { lookupFn }); |
| } |
|
|
| function withPinnedLookup( |
| lookup: PinnedHostname["lookup"], |
| connect?: Record<string, unknown>, |
| ): Record<string, unknown> { |
| return connect ? { ...connect, lookup } : { lookup }; |
| } |
|
|
| export function createPinnedDispatcher( |
| pinned: PinnedHostname, |
| policy?: PinnedDispatcherPolicy, |
| ): Dispatcher { |
| if (!policy || policy.mode === "direct") { |
| return new Agent({ |
| connect: withPinnedLookup(pinned.lookup, policy?.connect), |
| }); |
| } |
|
|
| if (policy.mode === "env-proxy") { |
| return new EnvHttpProxyAgent({ |
| connect: withPinnedLookup(pinned.lookup, policy.connect), |
| ...(policy.proxyTls ? { proxyTls: { ...policy.proxyTls } } : {}), |
| }); |
| } |
|
|
| const proxyUrl = policy.proxyUrl.trim(); |
| if (!policy.proxyTls) { |
| return new ProxyAgent(proxyUrl); |
| } |
| return new ProxyAgent({ |
| uri: proxyUrl, |
| proxyTls: { ...policy.proxyTls }, |
| }); |
| } |
|
|
| export async function closeDispatcher(dispatcher?: Dispatcher | null): Promise<void> { |
| if (!dispatcher) { |
| return; |
| } |
| const candidate = dispatcher as { close?: () => Promise<void> | void; destroy?: () => void }; |
| try { |
| if (typeof candidate.close === "function") { |
| await candidate.close(); |
| return; |
| } |
| if (typeof candidate.destroy === "function") { |
| candidate.destroy(); |
| } |
| } catch { |
| |
| } |
| } |
|
|
| export async function assertPublicHostname( |
| hostname: string, |
| lookupFn: LookupFn = dnsLookup, |
| ): Promise<void> { |
| await resolvePinnedHostname(hostname, lookupFn); |
| } |
|
|