fix(ssrf): centralize host/ip block checks

This commit is contained in:
Peter Steinberger
2026-02-22 15:41:32 +01:00
parent 39be5e44df
commit 44dfbd23df
3 changed files with 33 additions and 11 deletions

View File

@@ -316,6 +316,8 @@ const BLOCKED_IPV4_SPECIAL_USE_CIDRS: readonly Ipv4Cidr[] = [
{ base: [240, 0, 0, 0], prefixLength: 4 },
];
// Keep this table as the single source of IPv4 non-global policy.
// Both plain IPv4 literals and IPv6-embedded IPv4 forms flow through it.
const BLOCKED_IPV4_SPECIAL_USE_RANGES = BLOCKED_IPV4_SPECIAL_USE_CIDRS.map(ipv4RangeFromCidr);
function isBlockedIpv4SpecialUse(parts: number[]): boolean {
@@ -430,6 +432,24 @@ export function isBlockedHostnameOrIp(hostname: string): boolean {
return isBlockedHostnameNormalized(normalized) || isPrivateIpAddress(normalized);
}
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): void {
if (isBlockedHostnameOrIp(hostnameOrIp)) {
throw new SsrFBlockedError(BLOCKED_HOST_OR_IP_MESSAGE);
}
}
function assertAllowedResolvedAddressesOrThrow(results: readonly LookupAddress[]): void {
for (const entry of results) {
// Reuse the exact same host/IP classifier as the pre-DNS check to avoid drift.
if (isBlockedHostnameOrIp(entry.address)) {
throw new SsrFBlockedError(BLOCKED_RESOLVED_IP_MESSAGE);
}
}
}
export function createPinnedLookup(params: {
hostname: string;
addresses: string[];
@@ -506,13 +526,15 @@ export async function resolvePinnedHostnameWithPolicy(
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 (!allowPrivateNetwork && !isExplicitAllowed && isBlockedHostnameOrIp(normalized)) {
throw new SsrFBlockedError("Blocked hostname or private/internal/special-use IP address");
if (!skipPrivateNetworkChecks) {
// Phase 1: fail fast for literal hosts/IPs before any DNS lookup side-effects.
assertAllowedHostOrIpOrThrow(normalized);
}
const lookupFn = params.lookupFn ?? dnsLookup;
@@ -521,12 +543,9 @@ export async function resolvePinnedHostnameWithPolicy(
throw new Error(`Unable to resolve hostname: ${hostname}`);
}
if (!allowPrivateNetwork && !isExplicitAllowed) {
for (const entry of results) {
if (isPrivateIpAddress(entry.address)) {
throw new SsrFBlockedError("Blocked: resolves to private/internal/special-use IP address");
}
}
if (!skipPrivateNetworkChecks) {
// Phase 2: re-check DNS answers so public hostnames cannot pivot to private targets.
assertAllowedResolvedAddressesOrThrow(results);
}
const addresses = Array.from(new Set(results.map((entry) => entry.address)));