327 lines
10 KiB
TypeScript
327 lines
10 KiB
TypeScript
/**
|
|
* SSRF (Server-Side Request Forgery) protection utilities.
|
|
*
|
|
* Validates URLs to prevent requests to internal/private network addresses.
|
|
* Used by any API route that fetches a user-supplied URL server-side.
|
|
*/
|
|
import { promises as dns } from 'node:dns';
|
|
import { isIP } from 'node:net';
|
|
|
|
function normalizeAddress(value: string): string {
|
|
let normalized = value.trim().toLowerCase();
|
|
if (normalized.startsWith('[') && normalized.endsWith(']')) {
|
|
normalized = normalized.slice(1, -1);
|
|
}
|
|
return normalized.replace(/\.+$/, '');
|
|
}
|
|
|
|
function parseIPv4(ip: string): number[] | null {
|
|
const parts = ip.split('.');
|
|
if (parts.length !== 4) return null;
|
|
|
|
const octets = parts.map((part) => {
|
|
if (!/^\d+$/.test(part)) {
|
|
return Number.NaN;
|
|
}
|
|
return Number.parseInt(part, 10);
|
|
});
|
|
|
|
if (octets.some((octet) => Number.isNaN(octet) || octet < 0 || octet > 255)) {
|
|
return null;
|
|
}
|
|
|
|
return octets;
|
|
}
|
|
|
|
function ipv4ToUint32(octets: number[]): number {
|
|
return (((octets[0] << 24) >>> 0) | (octets[1] << 16) | (octets[2] << 8) | octets[3]) >>> 0;
|
|
}
|
|
|
|
function matchesIPv4Cidr(ip: number[], network: string, prefixLength: number): boolean {
|
|
const networkOctets = parseIPv4(network);
|
|
if (!networkOctets) return false;
|
|
|
|
const mask = prefixLength === 0 ? 0 : (0xffffffff << (32 - prefixLength)) >>> 0;
|
|
return (ipv4ToUint32(ip) & mask) === (ipv4ToUint32(networkOctets) & mask);
|
|
}
|
|
|
|
const NON_GLOBAL_IPV4_RANGES = [
|
|
['0.0.0.0', 8],
|
|
['10.0.0.0', 8],
|
|
['100.64.0.0', 10],
|
|
['127.0.0.0', 8],
|
|
['169.254.0.0', 16],
|
|
['172.16.0.0', 12],
|
|
['192.0.0.0', 24],
|
|
['192.0.2.0', 24],
|
|
['192.31.196.0', 24],
|
|
['192.52.193.0', 24],
|
|
['192.88.99.0', 24],
|
|
['192.168.0.0', 16],
|
|
['192.175.48.0', 24],
|
|
['198.18.0.0', 15],
|
|
['198.51.100.0', 24],
|
|
['203.0.113.0', 24],
|
|
['224.0.0.0', 4],
|
|
['240.0.0.0', 4],
|
|
] as const;
|
|
|
|
function extractMappedIPv4(ip: string): string | null {
|
|
const normalized = normalizeAddress(ip);
|
|
if (!normalized.startsWith('::ffff:')) {
|
|
return null;
|
|
}
|
|
|
|
const suffix = normalized.slice('::ffff:'.length);
|
|
const dottedIPv4 = parseIPv4(suffix);
|
|
if (dottedIPv4) {
|
|
return dottedIPv4.join('.');
|
|
}
|
|
|
|
const parts = suffix.split(':');
|
|
if (parts.length !== 2 || parts.some((part) => !/^[0-9a-f]{1,4}$/.test(part))) {
|
|
return null;
|
|
}
|
|
|
|
const [high, low] = parts.map((part) => Number.parseInt(part, 16));
|
|
return [high >> 8, high & 0xff, low >> 8, low & 0xff].join('.');
|
|
}
|
|
|
|
function getFirstIPv6Hextet(ip: string): number | null {
|
|
const normalized = normalizeAddress(ip);
|
|
if (!normalized.includes(':')) {
|
|
return null;
|
|
}
|
|
|
|
if (normalized.startsWith('::')) {
|
|
return 0;
|
|
}
|
|
|
|
const [firstHextet] = normalized.split(':');
|
|
if (!firstHextet || !/^[0-9a-f]{1,4}$/.test(firstHextet)) {
|
|
return null;
|
|
}
|
|
|
|
return Number.parseInt(firstHextet, 16);
|
|
}
|
|
|
|
/** Expand an IPv6 address into 8 numeric hextets. Returns null for invalid input. */
|
|
function expandIPv6(ip: string): number[] | null {
|
|
let normalized = normalizeAddress(ip);
|
|
if (!normalized.includes(':')) return null;
|
|
|
|
const lastPart = normalized.split(':').pop() || '';
|
|
if (lastPart.includes('.')) {
|
|
const dottedIPv4 = parseIPv4(lastPart);
|
|
if (!dottedIPv4) return null;
|
|
|
|
const [first, second, third, fourth] = dottedIPv4;
|
|
const high = ((first << 8) | second).toString(16);
|
|
const low = ((third << 8) | fourth).toString(16);
|
|
normalized = `${normalized.slice(0, -lastPart.length)}${high}:${low}`;
|
|
}
|
|
|
|
const sides = normalized.split('::');
|
|
if (sides.length > 2) return null;
|
|
|
|
let parts: string[];
|
|
if (sides.length === 2) {
|
|
const left = sides[0] ? sides[0].split(':') : [];
|
|
const right = sides[1] ? sides[1].split(':') : [];
|
|
const missing = 8 - left.length - right.length;
|
|
if (missing <= 0) return null;
|
|
parts = [...left, ...Array(missing).fill('0'), ...right];
|
|
} else {
|
|
parts = normalized.split(':');
|
|
}
|
|
|
|
if (parts.length !== 8) return null;
|
|
if (parts.some((p) => !/^[0-9a-f]{1,4}$/.test(p))) return null;
|
|
|
|
return parts.map((p) => Number.parseInt(p, 16));
|
|
}
|
|
|
|
function matchesIPv6Cidr(hextets: number[], network: number[], prefixLength: number): boolean {
|
|
let remaining = prefixLength;
|
|
for (let index = 0; index < 8 && remaining > 0; index += 1) {
|
|
const bits = Math.min(remaining, 16);
|
|
const mask = bits === 16 ? 0xffff : (0xffff << (16 - bits)) & 0xffff;
|
|
if ((hextets[index] & mask) !== (network[index] & mask)) {
|
|
return false;
|
|
}
|
|
remaining -= bits;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Return whether an IP is ordinary, publicly routable unicast.
|
|
*
|
|
* This is deliberately stricter than `isPrivateIP`: public URL proxies must
|
|
* also reject documentation, benchmark, translation, multicast, reserved and
|
|
* transition ranges. Blocking an unusual special-purpose address is safer than
|
|
* treating it as an Internet media origin.
|
|
*/
|
|
export function isGlobalUnicastIP(ip: string): boolean {
|
|
const normalized = normalizeAddress(ip);
|
|
const mappedIPv4 = extractMappedIPv4(normalized);
|
|
if (mappedIPv4) {
|
|
// IPv4-mapped literals are not native global-unicast destinations and can
|
|
// behave differently across connector implementations. Callers should use
|
|
// the canonical IPv4 literal instead.
|
|
return false;
|
|
}
|
|
|
|
// Preserve the tunnel/interface checks already used by the broader SSRF
|
|
// guard (6to4, Teredo and ISATAP with an embedded private IPv4 address).
|
|
if (isPrivateIP(normalized)) return false;
|
|
|
|
const ipv4 = parseIPv4(normalized);
|
|
if (ipv4) {
|
|
return !NON_GLOBAL_IPV4_RANGES.some(([network, prefixLength]) =>
|
|
matchesIPv4Cidr(ipv4, network, prefixLength),
|
|
);
|
|
}
|
|
|
|
const hextets = expandIPv6(normalized);
|
|
if (!hextets) return false;
|
|
|
|
// Today's ordinary IPv6 global-unicast space is 2000::/3. Exclude the
|
|
// special-purpose subranges within it as well.
|
|
if ((hextets[0] & 0xe000) !== 0x2000) return false;
|
|
if (matchesIPv6Cidr(hextets, [0x2001, 0, 0, 0, 0, 0, 0, 0], 23)) return false;
|
|
if (matchesIPv6Cidr(hextets, [0x2001, 0x0db8, 0, 0, 0, 0, 0, 0], 32)) return false;
|
|
if (matchesIPv6Cidr(hextets, [0x2002, 0, 0, 0, 0, 0, 0, 0], 16)) return false;
|
|
if (matchesIPv6Cidr(hextets, [0x2620, 0x004f, 0x8000, 0, 0, 0, 0, 0], 48)) return false;
|
|
if (matchesIPv6Cidr(hextets, [0x3fff, 0, 0, 0, 0, 0, 0, 0], 20)) return false;
|
|
|
|
return true;
|
|
}
|
|
|
|
export function isPrivateIP(ip: string): boolean {
|
|
const normalized = normalizeAddress(ip);
|
|
const mappedIPv4 = extractMappedIPv4(normalized);
|
|
if (mappedIPv4) {
|
|
return isPrivateIP(mappedIPv4);
|
|
}
|
|
|
|
const ipv4 = parseIPv4(normalized);
|
|
if (ipv4) {
|
|
const [first, second, third, fourth] = ipv4;
|
|
return (
|
|
first === 0 ||
|
|
first === 10 ||
|
|
first === 127 ||
|
|
(first === 100 && second >= 64 && second <= 127) || // RFC 6598 shared/CGNAT
|
|
(first === 169 && second === 254) ||
|
|
(first === 172 && second >= 16 && second <= 31) ||
|
|
(first === 192 && second === 168) ||
|
|
(first === 0 && second === 0 && third === 0 && fourth === 0)
|
|
);
|
|
}
|
|
|
|
const ipv6FirstHextet = getFirstIPv6Hextet(normalized);
|
|
if (ipv6FirstHextet === null) {
|
|
return false;
|
|
}
|
|
|
|
if (normalized === '::' || normalized === '::1') {
|
|
return true;
|
|
}
|
|
|
|
if (
|
|
(ipv6FirstHextet & 0xfe00) === 0xfc00 || // fc00::/7 unique local
|
|
(ipv6FirstHextet & 0xffc0) === 0xfe80 || // fe80::/10 link-local
|
|
(ipv6FirstHextet & 0xffc0) === 0xfec0 // fec0::/10 site-local (deprecated)
|
|
) {
|
|
return true;
|
|
}
|
|
|
|
// 6to4 tunnel: 2002::/16 — embedded IPv4 sits in bits 16-47
|
|
if (ipv6FirstHextet === 0x2002) {
|
|
const hextets = expandIPv6(normalized);
|
|
if (hextets) {
|
|
const embedded = `${hextets[1] >> 8}.${hextets[1] & 0xff}.${hextets[2] >> 8}.${hextets[2] & 0xff}`;
|
|
if (isPrivateIP(embedded)) return true;
|
|
}
|
|
}
|
|
|
|
// Teredo tunnel: 2001:0000::/32 — client IPv4 in last 32 bits, XOR-inverted
|
|
if (ipv6FirstHextet === 0x2001) {
|
|
const hextets = expandIPv6(normalized);
|
|
if (hextets && hextets[1] === 0x0000) {
|
|
const high = hextets[6] ^ 0xffff;
|
|
const low = hextets[7] ^ 0xffff;
|
|
const embedded = `${high >> 8}.${high & 0xff}.${low >> 8}.${low & 0xff}`;
|
|
if (isPrivateIP(embedded)) return true;
|
|
}
|
|
}
|
|
|
|
// ISATAP interface ID: 0000:5efe:<IPv4> or 0200:5efe:<IPv4>
|
|
const hextets = expandIPv6(normalized);
|
|
if (hextets && (hextets[4] === 0x0000 || hextets[4] === 0x0200) && hextets[5] === 0x5efe) {
|
|
const embedded = `${hextets[6] >> 8}.${hextets[6] & 0xff}.${hextets[7] >> 8}.${hextets[7] & 0xff}`;
|
|
if (isPrivateIP(embedded)) return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
const LOCAL_NETWORK_BLOCK_MESSAGE =
|
|
'Local/private network URLs are not allowed. If this is a self-hosted deployment or internal gateway (including split-horizon DNS), set ALLOW_LOCAL_NETWORKS=true to allow local network targets.';
|
|
|
|
/**
|
|
* Validate a URL against SSRF attacks.
|
|
* Returns null if the URL is safe, or an error message string if blocked.
|
|
*/
|
|
export async function validateUrlForSSRF(url: string): Promise<string | null> {
|
|
let parsed: URL;
|
|
try {
|
|
parsed = new URL(url);
|
|
} catch {
|
|
return 'Invalid URL';
|
|
}
|
|
|
|
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
|
|
return 'Only HTTP(S) URLs are allowed';
|
|
}
|
|
|
|
// Self-hosted deployments can set ALLOW_LOCAL_NETWORKS=true to skip private-IP checks
|
|
const allowLocal = process.env.ALLOW_LOCAL_NETWORKS;
|
|
if (allowLocal === 'true' || allowLocal === '1') {
|
|
return null;
|
|
}
|
|
|
|
const hostname = normalizeAddress(parsed.hostname);
|
|
if (
|
|
hostname === 'localhost' ||
|
|
hostname.endsWith('.local') ||
|
|
hostname === '0.0.0.0' ||
|
|
hostname === '::1' ||
|
|
isPrivateIP(hostname)
|
|
) {
|
|
return LOCAL_NETWORK_BLOCK_MESSAGE;
|
|
}
|
|
|
|
if (isIP(hostname)) {
|
|
return null;
|
|
}
|
|
|
|
let resolvedAddresses: Array<{ address: string; family: number }>;
|
|
try {
|
|
resolvedAddresses = await dns.lookup(hostname, { all: true, verbatim: true });
|
|
} catch {
|
|
return 'Unable to verify hostname safety';
|
|
}
|
|
|
|
if (resolvedAddresses.length === 0) {
|
|
return 'Unable to verify hostname safety';
|
|
}
|
|
|
|
if (resolvedAddresses.some(({ address }) => isPrivateIP(address))) {
|
|
return LOCAL_NETWORK_BLOCK_MESSAGE;
|
|
}
|
|
|
|
return null;
|
|
}
|