114 lines
3.7 KiB
JavaScript
114 lines
3.7 KiB
JavaScript
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 };
|