Files
openmaic/OpenMAIC/lib/server/ssrf-guard.ts
2026-08-16 14:58:47 +08:00

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;
}