const { URL } = require('url'); const dns = require('dns').promises; const net = require('net'); // ---- IPv4 private / reserved ranges ---- const V4_BLOCKED = [ /^10\./, /^172\.(1[6-9]|2\d|3[01])\./, /^192\.168\./, /^127\./, /^0\./, /^169\.254\./, // link-local (incl. cloud metadata 169.254.169.254) /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./, // CGNAT 100.64.0.0/10 ]; function isPrivateV4(ip) { return V4_BLOCKED.some((re) => re.test(ip)); } // Convert an IPv4-mapped IPv6 address to dotted IPv4, or null. // Handles both "::ffff:127.0.0.1" and hex form "::ffff:7f00:1". function mappedToV4(ip) { const m = ip.match(/^::ffff:([0-9a-f.:]+)$/i); if (!m) return null; const tail = m[1]; if (tail.includes('.')) return tail; const parts = tail.split(':'); if (parts.length === 2) { const hi = parseInt(parts[0], 16); const lo = parseInt(parts[1], 16); if (Number.isNaN(hi) || Number.isNaN(lo)) return null; return [(hi >> 8) & 255, hi & 255, (lo >> 8) & 255, lo & 255].join('.'); } return null; } // Returns true if the address is private/reserved (or unrecognised → fail-closed). function isPrivateIP(ipRaw) { if (!ipRaw) return true; const ip = String(ipRaw).replace(/^\[|\]$/g, '').toLowerCase(); const ver = net.isIP(ip); if (ver === 4) return isPrivateV4(ip); if (ver === 6) { const asV4 = mappedToV4(ip); if (asV4) return isPrivateV4(asV4); if (ip === '::1' || ip === '::') return true; // loopback / unspecified if (/^(fc|fd)/.test(ip)) return true; // ULA fc00::/7 if (/^fe[89ab]/.test(ip)) return true; // link-local fe80::/10 return false; } // Not a bare IP literal — caller should not pass hostnames here; treat as unsafe. return true; } /** * Validate URL is safe for server-side requests (no SSRF). * Resolves BOTH A and AAAA and fails CLOSED on resolution errors. * Returns { safe: true, url, addresses } or { safe: false, error }. */ async function validateUrl(rawUrl) { if (!rawUrl || typeof rawUrl !== 'string') { return { safe: false, error: 'URL обязателен' }; } let parsed; try { parsed = new URL(rawUrl); } catch { return { safe: false, error: 'Некорректный URL' }; } if (!['http:', 'https:'].includes(parsed.protocol)) { return { safe: false, error: 'Поддерживаются только http и https' }; } // hostname without IPv6 brackets const host = parsed.hostname.replace(/^\[|\]$/g, ''); // Direct IP literal (v4 or v6) if (net.isIP(host)) { if (isPrivateIP(host)) { return { safe: false, error: 'Доступ к внутренним адресам запрещён' }; } return { safe: true, url: rawUrl }; } // Common internal hostnames const lower = host.toLowerCase(); if (lower === 'localhost' || lower.endsWith('.local') || lower.endsWith('.internal') || lower.endsWith('.localhost')) { return { safe: false, error: 'Доступ к внутренним адресам запрещён' }; } // Resolve BOTH A and AAAA; fail CLOSED if nothing resolves. let addrs = []; const [v4, v6] = await Promise.allSettled([dns.resolve4(host), dns.resolve6(host)]); if (v4.status === 'fulfilled') addrs = addrs.concat(v4.value); if (v6.status === 'fulfilled') addrs = addrs.concat(v6.value); if (addrs.length === 0) { return { safe: false, error: 'Не удалось разрешить имя хоста' }; } for (const addr of addrs) { if (isPrivateIP(addr)) { return { safe: false, error: 'Доступ к внутренним адресам запрещён' }; } } return { safe: true, url: rawUrl, addresses: addrs }; } module.exports = { validateUrl, isPrivateIP };