import dns from 'node:dns/promises'; import net from 'node:net'; // Värdnamn som medvetet tillåts trots att de pekar lokalt (t.ex. lokal Ollama). export const LOCAL_ALLOWED_HOSTS = new Set([ 'host.docker.internal', 'localhost', '127.0.0.1', '::1', ]); function isPrivateIp(ip) { if (net.isIPv4(ip)) { const [a, b] = ip.split('.').map(Number); return ( a === 10 || a === 127 || a === 0 || (a === 192 && b === 168) || (a === 172 && b >= 16 && b <= 31) || (a === 169 && b === 254) // link-local / cloud-metadata ); } if (net.isIPv6(ip)) { const lower = ip.toLowerCase(); return ( lower === '::1' || lower === '::' || lower.startsWith('fc') || lower.startsWith('fd') || lower.startsWith('fe80') || lower.startsWith('::ffff:') // IPv4-mappad ); } return true; // okänt format → blockera } /** * Validerar en URL mot SSRF. Kastar om adressen är ogiltig eller pekar internt. * @param {string} rawUrl * @param {{ allowList?: Set, allowPrivateNetwork?: boolean }} [options] - värdnamn som tillåts trots privat IP * @returns {Promise} */ export async function assertSafeUrl(rawUrl, { allowList = new Set(), allowPrivateNetwork = false } = {}) { let url; try { url = new URL(String(rawUrl || '')); } catch { throw new Error('Ogiltig URL'); } if (!['http:', 'https:'].includes(url.protocol)) { throw new Error('Endast http/https tillåts'); } if (allowList.has(url.hostname)) return url; // Om värden redan är en literal IP behöver vi ingen DNS-lookup. if (net.isIP(url.hostname)) { if (!allowPrivateNetwork && isPrivateIp(url.hostname)) throw new Error('Adressen pekar mot ett internt nät'); return url; } let resolved; try { resolved = await dns.lookup(url.hostname, { all: true }); } catch { throw new Error('Kunde inte slå upp värdnamnet'); } if (!resolved.length) { throw new Error('Kunde inte slå upp värdnamnet'); } if (!allowPrivateNetwork && resolved.some(entry => isPrivateIp(entry.address))) { throw new Error('Adressen pekar mot ett internt nät'); } return url; }