Files
ipwhitelist-app/v2/src/validators/index.js
T

127 lines
6.7 KiB
JavaScript
Raw 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.
// ═══════════════════════════════════════════════════════════════════════════════
// V2 — валидация CIDR по ТЗ
//
// ЭТО: чистые функции. НЕ: БД, Express, сеть.
//
// ЗАЧЕМ:
// 1. Единственное место где определены правила ТЗ (диапазоны, маски).
// 2. Вызывается в crud/ перед каждым добавлением/изменением.
// 3. Если ТЗ поменяется — править только здесь.
//
// ПРОВЕРКИ (по порядку):
// 1. Пустое значение
// 2. IPv6 (не поддерживается)
// 3. Двойной слеш (некорректный формат)
// 4. Маска /22–/32 (ТЗ: нельзя шире /22)
// 5. Валидный IPv4-адрес
// 6. Нормализация хостовой части (13.0.0.5/24 → 13.0.0.0/24)
// 7. Пересечение с BLOCKED_RANGES (14 диапазонов из Приложения А ТЗ)
// ═══════════════════════════════════════════════════════════════════════════════
const net = require('net');
// ── BLOCKED_RANGES — Приложение А ТЗ (14 диапазонов) ────────────────────────
// Это ЕДИНСТВЕННОЕ место где хранятся запрещённые диапазоны.
// При изменении ТЗ — править ТОЛЬКО этот массив.
const BLOCKED_RANGES = [
'10.0.0.0/8', // Private RFC1918
'172.16.0.0/12', // Private RFC1918
'192.168.0.0/16', // Private RFC1918
'100.64.0.0/10', // CGNAT RFC6598
'127.0.0.0/8', // Loopback
'169.254.0.0/16', // Link-local
'192.0.0.0/24', // IANA special
'192.0.2.0/24', // TEST-NET-1
'198.51.100.0/24', // TEST-NET-2
'203.0.113.0/24', // TEST-NET-3
'198.18.0.0/15', // Benchmarking
'224.0.0.0/4', // Multicast
'240.0.0.0/4', // Reserved Class E
'0.0.0.0/8', // "This" network
'255.255.255.255/32',// Broadcast
];
// ── validate(input) → { cidr, wasNormalized } ───────────────────────────────
// ПОЛНАЯ проверка по ТЗ.
// wasNormalized=true — если хостовая часть была обнулена (13.0.0.5/24 → 13.0.0.0/24).
// Бросает Error с русским текстом — пользователь видит в UI.
function validate(input) {
const raw = (input || '').trim();
if (!raw) throw new Error('Пустое значение');
if (raw.includes(':')) throw new Error('IPv6 не поддерживается');
if ((raw.match(/\//g) || []).length > 1) throw new Error('Некорректный формат');
// Если маска не указана — считаем /32 (одиночный хост)
let cidr = raw.includes('/') ? raw : raw + '/32';
const [addr, maskStr] = cidr.split('/');
// Маска: только цифры, одна или две
if (!/^\d{1,2}$/.test(maskStr)) throw new Error('Некорректная маска');
const mask = parseInt(maskStr, 10);
if (mask < 22 || mask > 32) throw new Error('Маска должна быть от /22 до /32');
if (!net.isIPv4(addr)) throw new Error('Некорректный IPv4 адрес');
// Нормализация: обнуляем хостовую часть битовой маской
const ipNum = addr.split('.').reduce((acc, o) => (acc << 8) + parseInt(o, 10), 0) >>> 0;
const netMask = ~((1 << (32 - mask)) - 1) >>> 0;
const network = (ipNum & netMask) >>> 0;
const networkAddr = [
(network >>> 24) & 0xff, (network >>> 16) & 0xff,
(network >>> 8) & 0xff, network & 0xff,
].join('.');
const normalized = networkAddr + '/' + mask;
// Проверка против ВСЕХ запрещённых диапазонов
for (const blocked of BLOCKED_RANGES)
if (overlaps(normalized, blocked))
throw new Error(`Диапазон ${normalized} пересекается с запрещённым (${blocked})`);
return { cidr: normalized, wasNormalized: addr !== networkAddr };
}
// ── overlaps(cidr1, cidr2) → boolean ───────────────────────────────────────
// Проверяет пересечение двух CIDR-диапазонов.
// Используется: validate() для BLOCKED_RANGES, db/queries для проверки дубликатов.
function overlaps(cidr1, cidr2) {
const a = cidrToRange(cidr1), b = cidrToRange(cidr2);
return a.start <= b.end && b.start <= a.end;
}
// ── cidrToRange(cidr) → { start, end } ──────────────────────────────────────
// CIDR → числовой диапазон (start/end — uint32 IP-адреса).
function cidrToRange(cidr) {
const [addr, maskStr] = cidr.split('/');
const ip = addr.split('.').reduce((acc, o) => (acc << 8) + parseInt(o, 10), 0) >>> 0;
const mask = parseInt(maskStr, 10);
return { start: ip, end: (ip | ((1 << (32 - mask)) - 1)) >>> 0 };
}
// ── aggregateCIDRs(cidrs) → string[] ───────────────────────────────────────
// Схлопывание списка CIDR в минимальный набор (для экспорта).
// Пример: 10.0.0.0/24 + 10.0.1.0/24 → 10.0.0.0/23
function aggregateCIDRs(cidrs) {
const ranges = cidrs.map(c => cidrToRange(c)).sort((a, b) => a.start - b.start);
const merged = [];
for (const r of ranges) {
const last = merged[merged.length - 1];
if (last && r.start <= last.end + 1) last.end = Math.max(last.end, r.end);
else merged.push({ ...r });
}
return merged.map(r => {
let start = r.start, end = r.end, cidrs = [];
while (start <= end) {
let maxMask = 32;
while (maxMask > 0 && ((start & ((1 << (32 - maxMask)) - 1)) !== 0 || (start + (1 << (32 - maxMask)) - 1) > end)) maxMask--;
cidrs.push(numToIP(start) + '/' + maxMask);
start += (1 << (32 - maxMask));
}
return cidrs;
}).flat();
}
function numToIP(n) {
return [(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff].join('.');
}
module.exports = { validate, overlaps, cidrToRange, aggregateCIDRs, numToIP, BLOCKED_RANGES };