fix: harden web fetch SSRF and redirects

Co-authored-by: Eli <fogboots@users.noreply.github.com>
This commit is contained in:
Peter Steinberger
2026-01-21 02:52:27 +00:00
parent ec51bb700c
commit 5bd55037e4
11 changed files with 412 additions and 82 deletions

131
src/infra/net/ssrf.ts Normal file
View File

@@ -0,0 +1,131 @@
import { lookup as dnsLookup } from "node:dns/promises";
export class SsrFBlockedError extends Error {
constructor(message: string) {
super(message);
this.name = "SsrFBlockedError";
}
}
type LookupFn = typeof dnsLookup;
const PRIVATE_IPV6_PREFIXES = ["fe80:", "fec0:", "fc", "fd"];
const BLOCKED_HOSTNAMES = new Set(["localhost", "metadata.google.internal"]);
function normalizeHostname(hostname: string): string {
const normalized = hostname.trim().toLowerCase().replace(/\.$/, "");
if (normalized.startsWith("[") && normalized.endsWith("]")) {
return normalized.slice(1, -1);
}
return normalized;
}
function parseIpv4(address: string): number[] | null {
const parts = address.split(".");
if (parts.length !== 4) return null;
const numbers = parts.map((part) => Number.parseInt(part, 10));
if (numbers.some((value) => Number.isNaN(value) || value < 0 || value > 255)) return null;
return numbers;
}
function parseIpv4FromMappedIpv6(mapped: string): number[] | null {
if (mapped.includes(".")) {
return parseIpv4(mapped);
}
const parts = mapped.split(":").filter(Boolean);
if (parts.length === 1) {
const value = Number.parseInt(parts[0], 16);
if (Number.isNaN(value) || value < 0 || value > 0xffff_ffff) return null;
return [(value >>> 24) & 0xff, (value >>> 16) & 0xff, (value >>> 8) & 0xff, value & 0xff];
}
if (parts.length !== 2) return null;
const high = Number.parseInt(parts[0], 16);
const low = Number.parseInt(parts[1], 16);
if (
Number.isNaN(high) ||
Number.isNaN(low) ||
high < 0 ||
low < 0 ||
high > 0xffff ||
low > 0xffff
) {
return null;
}
const value = (high << 16) + low;
return [(value >>> 24) & 0xff, (value >>> 16) & 0xff, (value >>> 8) & 0xff, value & 0xff];
}
function isPrivateIpv4(parts: number[]): boolean {
const [octet1, octet2] = parts;
if (octet1 === 0) return true;
if (octet1 === 10) return true;
if (octet1 === 127) return true;
if (octet1 === 169 && octet2 === 254) return true;
if (octet1 === 172 && octet2 >= 16 && octet2 <= 31) return true;
if (octet1 === 192 && octet2 === 168) return true;
if (octet1 === 100 && octet2 >= 64 && octet2 <= 127) return true;
return false;
}
export function isPrivateIpAddress(address: string): boolean {
let normalized = address.trim().toLowerCase();
if (normalized.startsWith("[") && normalized.endsWith("]")) {
normalized = normalized.slice(1, -1);
}
if (!normalized) return false;
if (normalized.startsWith("::ffff:")) {
const mapped = normalized.slice("::ffff:".length);
const ipv4 = parseIpv4FromMappedIpv6(mapped);
if (ipv4) return isPrivateIpv4(ipv4);
}
if (normalized.includes(":")) {
if (normalized === "::" || normalized === "::1") return true;
return PRIVATE_IPV6_PREFIXES.some((prefix) => normalized.startsWith(prefix));
}
const ipv4 = parseIpv4(normalized);
if (!ipv4) return false;
return isPrivateIpv4(ipv4);
}
export function isBlockedHostname(hostname: string): boolean {
const normalized = normalizeHostname(hostname);
if (!normalized) return false;
if (BLOCKED_HOSTNAMES.has(normalized)) return true;
return (
normalized.endsWith(".localhost") ||
normalized.endsWith(".local") ||
normalized.endsWith(".internal")
);
}
export async function assertPublicHostname(
hostname: string,
lookupFn: LookupFn = dnsLookup,
): Promise<void> {
const normalized = normalizeHostname(hostname);
if (!normalized) {
throw new Error("Invalid hostname");
}
if (isBlockedHostname(normalized)) {
throw new SsrFBlockedError(`Blocked hostname: ${hostname}`);
}
if (isPrivateIpAddress(normalized)) {
throw new SsrFBlockedError("Blocked: private/internal IP address");
}
const results = await lookupFn(normalized, { all: true });
if (results.length === 0) {
throw new Error(`Unable to resolve hostname: ${hostname}`);
}
for (const entry of results) {
if (isPrivateIpAddress(entry.address)) {
throw new SsrFBlockedError("Blocked: resolves to private/internal IP address");
}
}
}