wa-dev-tools/lib/ssrf.js

114 lines
3.7 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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