| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218 |
- /**
- * 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 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 {
- const normalized = normalizeAddress(ip);
- if (!normalized.includes(':')) return null;
- // Skip IPv4-suffix forms (handled separately by extractMappedIPv4)
- const lastPart = normalized.split(':').pop() || '';
- if (lastPart.includes('.')) return null;
- 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));
- }
- 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 === 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;
- }
- }
- return false;
- }
- /**
- * 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/private network URLs are not allowed';
- }
- 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/private network URLs are not allowed';
- }
- return null;
- }
|