feat: Добавить улучшенный rate limiting для операций записи и BGP обновлений, а также валидацию входных данных для ASNs, доменов и IP диапазонов. Реализовать отображение статистики использования сообществ и новый API для валидации конфигурации MikroTik. Обновить интерфейс менеджера сообществ с вкладками для списка и статистики.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 3m30s

This commit is contained in:
2025-10-02 16:36:52 +07:00
parent c06945eefc
commit cfbcedf9d7
8 changed files with 1501 additions and 17 deletions
+177
View File
@@ -0,0 +1,177 @@
/**
* Модуль валидации синтаксиса MikroTik RouterOS конфигурации
*/
/**
* Валидация MikroTik конфигурации
* @param {string} config - Текст конфигурации
* @returns {object} { valid: boolean, errors: array, warnings: array }
*/
function validateMikrotikConfig(config) {
const errors = [];
const warnings = [];
if (!config || typeof config !== 'string') {
errors.push({ line: 0, message: 'Конфигурация пуста или неверного типа' });
return { valid: false, errors, warnings };
}
const lines = config.split('\n');
let inBlock = false;
let blockName = '';
let braceBalance = 0;
let currentBlockLine = 0;
for (let i = 0; i < lines.length; i++) {
const lineNum = i + 1;
const line = lines[i].trim();
// Пропускаем комментарии и пустые строки
if (line.startsWith('//') || line.startsWith('#') || line === '') {
continue;
}
// Проверка начала блока (например, /routing filter bgp-in-tmp {)
if (line.match(/^\/\w+(\s+\w+)*\s+\w+(-\w+)*\s*\{/)) {
if (inBlock) {
errors.push({ line: lineNum, message: `Вложенные блоки не поддерживаются в RouterOS` });
}
inBlock = true;
blockName = line.match(/^\/\w+(\s+\w+)*/)?.[0] || '';
currentBlockLine = lineNum;
braceBalance++;
continue;
}
// Подсчет фигурных скобок
const openBraces = (line.match(/\{/g) || []).length;
const closeBraces = (line.match(/\}/g) || []).length;
braceBalance += openBraces - closeBraces;
// Проверка закрытия блока
if (line === '}') {
if (braceBalance === 0) {
inBlock = false;
blockName = '';
}
continue;
}
// Проверка синтаксиса внутри блока
if (inBlock) {
// Проверка if конструкций
if (line.startsWith('if')) {
// Проверка наличия условия в скобках
if (!line.match(/if\s*\(/)) {
errors.push({ line: lineNum, message: `Отсутствует открывающая скобка после if` });
}
// Проверка сбалансированности скобок в условии
const conditionPart = line.substring(line.indexOf('('));
const openParens = (conditionPart.match(/\(/g) || []).length;
const closeParens = (conditionPart.match(/\)/g) || []).length;
if (openParens !== closeParens) {
errors.push({ line: lineNum, message: `Несбалансированные скобки в условии if` });
}
}
// Проверка команд set gw
if (line.match(/set\s+gw\s+/)) {
const match = line.match(/set\s+gw\s+([^;]+)/);
if (match) {
const gateway = match[1].trim();
// Проверка валидности имени gateway
if (!gateway || !/^[a-zA-Z0-9_-]+$/.test(gateway)) {
warnings.push({ line: lineNum, message: `Возможно неверное имя gateway: "${gateway}"` });
}
}
}
// Проверка команд accept/reject
if (line.match(/;\s*(accept|reject)\s*;/)) {
warnings.push({ line: lineNum, message: `Двойная точка с запятой перед ${line.includes('accept') ? 'accept' : 'reject'}` });
}
// Проверка bgp-communities
if (line.includes('bgp-communities includes')) {
const match = line.match(/bgp-communities\s+includes\s+(\S+)/);
if (match) {
const community = match[1].replace(/[()]/g, '');
// Базовая проверка формата community
if (!community.match(/^\d+:\d+$/)) {
warnings.push({ line: lineNum, message: `Community "${community}" может иметь неверный формат (ожидается N:N)` });
}
}
}
// Проверка else конструкций
if (line.startsWith('else')) {
// else должен следовать после закрывающей скобки if
const prevNonEmpty = lines.slice(0, i).reverse().find(l => l.trim() !== '' && !l.trim().startsWith('//'));
if (prevNonEmpty && !prevNonEmpty.trim().endsWith('}')) {
warnings.push({ line: lineNum, message: `else должен следовать после закрывающей скобки блока if` });
}
}
}
// Проверка команд вне блока
if (!inBlock && line.startsWith('/')) {
// Команды верхнего уровня должны начинаться с /
const validTopLevel = ['/routing', '/ip', '/interface', '/system'];
const isValidTopLevel = validTopLevel.some(cmd => line.startsWith(cmd));
if (!isValidTopLevel) {
warnings.push({ line: lineNum, message: `Неизвестная команда верхнего уровня: ${line.substring(0, 30)}` });
}
}
}
// Проверка сбалансированности фигурных скобок
if (braceBalance !== 0) {
errors.push({ line: 0, message: `Несбалансированные фигурные скобки (баланс: ${braceBalance})` });
}
// Проверка незакрытых блоков
if (inBlock) {
errors.push({ line: currentBlockLine, message: `Блок "${blockName}" не закрыт` });
}
return {
valid: errors.length === 0,
errors,
warnings
};
}
/**
* Проверка корректности community value
* @param {string} community - Community value (например, 65000:100)
* @returns {boolean}
*/
function isValidCommunity(community) {
if (!community || typeof community !== 'string') return false;
// Формат: N:N где N - число от 0 до 65535
const match = community.match(/^(\d+):(\d+)$/);
if (!match) return false;
const first = Number(match[1]);
const second = Number(match[2]);
return first >= 0 && first <= 65535 && second >= 0 && second <= 65535;
}
/**
* Проверка корректности имени gateway
* @param {string} gateway - Gateway имя
* @returns {boolean}
*/
function isValidGateway(gateway) {
if (!gateway || typeof gateway !== 'string') return false;
// Допустимы: буквы, цифры, дефис, подчеркивание
// Длина: 1-64 символа
return /^[a-zA-Z0-9_-]{1,64}$/.test(gateway);
}
module.exports = {
validateMikrotikConfig,
isValidCommunity,
isValidGateway,
};
+336
View File
@@ -0,0 +1,336 @@
/**
* Модуль валидации входных данных
* Содержит валидаторы для IP, CIDR, доменов, ASN, Community
*/
/**
* Валидация IPv4 адреса
* @param {string} ip - IP адрес для валидации
* @returns {boolean}
*/
function isValidIPv4(ip) {
if (!ip || typeof ip !== 'string') return false;
const octets = ip.trim().split('.');
if (octets.length !== 4) return false;
return octets.every(octet => {
if (!/^\d{1,3}$/.test(octet)) return false;
const num = Number(octet);
return num >= 0 && num <= 255;
});
}
/**
* Валидация IPv6 адреса (базовая)
* @param {string} ip - IPv6 адрес
* @returns {boolean}
*/
function isValidIPv6(ip) {
if (!ip || typeof ip !== 'string') return false;
const segments = ip.trim().split(':');
if (segments.length < 3 || segments.length > 8) return false;
return segments.every(seg => {
if (seg === '') return true; // :: notation
return /^[0-9a-fA-F]{1,4}$/.test(seg);
});
}
/**
* Валидация CIDR блока (IPv4)
* @param {string} cidr - CIDR блок (например, 192.168.1.0/24)
* @returns {boolean}
*/
function isValidCIDRv4(cidr) {
if (!cidr || typeof cidr !== 'string') return false;
const parts = cidr.trim().split('/');
if (parts.length !== 2) return false;
const [ip, mask] = parts;
if (!isValidIPv4(ip)) return false;
if (!/^\d{1,2}$/.test(mask)) return false;
const maskNum = Number(mask);
return maskNum >= 0 && maskNum <= 32;
}
/**
* Валидация CIDR блока (IPv6)
* @param {string} cidr - CIDR блок IPv6
* @returns {boolean}
*/
function isValidCIDRv6(cidr) {
if (!cidr || typeof cidr !== 'string') return false;
const parts = cidr.trim().split('/');
if (parts.length !== 2) return false;
const [ip, mask] = parts;
if (!isValidIPv6(ip)) return false;
if (!/^\d{1,3}$/.test(mask)) return false;
const maskNum = Number(mask);
return maskNum >= 0 && maskNum <= 128;
}
/**
* Валидация доменного имени (FQDN)
* Поддержка IDN (интернационализированных доменов)
* @param {string} domain - доменное имя
* @returns {boolean}
*/
function isValidDomain(domain) {
if (!domain || typeof domain !== 'string') return false;
const d = domain.trim().toLowerCase();
// Проверка на максимальную длину
if (d.length > 253) return false;
// Проверка на IP адрес (не должен быть IP)
if (isValidIPv4(d) || isValidIPv6(d)) return false;
// Базовая проверка структуры домена
// Разрешаем буквы, цифры, дефис, точку, и IDN символы
const domainRegex = /^([a-z0-9\u00a1-\uffff]([a-z0-9\u00a1-\uffff-]{0,61}[a-z0-9\u00a1-\uffff])?\.)+[a-z\u00a1-\uffff]{2,}$/i;
return domainRegex.test(d);
}
/**
* Валидация wildcard домена (*.example.com)
* @param {string} domain - wildcard домен
* @returns {boolean}
*/
function isValidWildcardDomain(domain) {
if (!domain || typeof domain !== 'string') return false;
const d = domain.trim().toLowerCase();
// Проверка на wildcard в начале
if (d.startsWith('*.')) {
const baseDomain = d.substring(2);
return isValidDomain(baseDomain);
}
return isValidDomain(d);
}
/**
* Валидация номера ASN
* @param {string|number} asn - номер ASN
* @returns {boolean}
*/
function isValidASN(asn) {
if (!asn) return false;
const asnStr = String(asn).trim();
// Формат: AS12345 или просто 12345
const asnRegex = /^(AS)?(\d{1,10})$/i;
const match = asnStr.match(asnRegex);
if (!match) return false;
const num = Number(match[2]);
// ASN диапазон: 0-4294967295 (32-bit)
return num >= 0 && num <= 4294967295;
}
/**
* Валидация BGP Community
* Поддержка форматов: 65000:100, 65000
* @param {string} community - BGP community
* @returns {boolean}
*/
function isValidCommunity(community) {
if (!community || typeof community !== 'string') return false;
const c = community.trim();
// Формат: N:N или просто N
const communityRegex = /^(\d{1,10})(:(\d{1,10}))?$/;
const match = c.match(communityRegex);
if (!match) return false;
const first = Number(match[1]);
if (first < 0 || first > 4294967295) return false;
if (match[3]) {
const second = Number(match[3]);
if (second < 0 || second > 65535) return false;
}
return true;
}
/**
* Валидация gateway имени (для MikroTik)
* @param {string} gateway - имя gateway
* @returns {boolean}
*/
function isValidGateway(gateway) {
if (!gateway || typeof gateway !== 'string') return false;
const g = gateway.trim();
// MikroTik gateway: буквы, цифры, дефис, подчеркивание
// Длина: 1-64 символа
if (g.length < 1 || g.length > 64) return false;
const gatewayRegex = /^[a-zA-Z0-9_-]+$/;
return gatewayRegex.test(g);
}
/**
* Санитизация строки от опасных символов
* @param {string} str - входная строка
* @returns {string}
*/
function sanitizeString(str) {
if (!str || typeof str !== 'string') return '';
// Удаляем управляющие символы
let cleaned = str.replace(/[\x00-\x1F\x7F]/g, '');
// Экранируем HTML специальные символы
cleaned = cleaned
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#x27;')
.replace(/\//g, '&#x2F;');
return cleaned.trim();
}
/**
* Проверка на SQL injection паттерны
* @param {string} str - строка для проверки
* @returns {boolean} true если безопасно
*/
function isSafeSQLString(str) {
if (!str || typeof str !== 'string') return true;
const dangerousPatterns = [
/(\bSELECT\b|\bINSERT\b|\bUPDATE\b|\bDELETE\b|\bDROP\b|\bCREATE\b)/i,
/(\bUNION\b|\bJOIN\b)/i,
/(--|;|\/\*|\*\/)/,
/(\bOR\b|\bAND\b)\s+\d+\s*=\s*\d+/i,
];
return !dangerousPatterns.some(pattern => pattern.test(str));
}
/**
* Проверка на XSS паттерны
* @param {string} str - строка для проверки
* @returns {boolean} true если безопасно
*/
function isSafeXSSString(str) {
if (!str || typeof str !== 'string') return true;
const dangerousPatterns = [
/<script/i,
/javascript:/i,
/on\w+\s*=/i, // onclick=, onerror=, etc
/<iframe/i,
/<embed/i,
/<object/i,
];
return !dangerousPatterns.some(pattern => pattern.test(str));
}
/**
* Комплексная валидация входных данных
* @param {object} data - объект с данными для валидации
* @param {object} schema - схема валидации
* @returns {object} { valid: boolean, errors: array }
*/
function validateData(data, schema) {
const errors = [];
for (const [field, rules] of Object.entries(schema)) {
const value = data[field];
// Проверка required
if (rules.required && (value === undefined || value === null || value === '')) {
errors.push({ field, message: `Поле ${field} обязательно` });
continue;
}
// Если поле не обязательно и пустое - пропускаем остальные проверки
if (!rules.required && (value === undefined || value === null || value === '')) {
continue;
}
// Проверка типа
if (rules.type) {
switch (rules.type) {
case 'ipv4':
if (!isValidIPv4(value)) {
errors.push({ field, message: `${field} должен быть валидным IPv4 адресом` });
}
break;
case 'cidrv4':
if (!isValidCIDRv4(value)) {
errors.push({ field, message: `${field} должен быть валидным CIDR блоком` });
}
break;
case 'domain':
if (!isValidDomain(value)) {
errors.push({ field, message: `${field} должен быть валидным доменом` });
}
break;
case 'asn':
if (!isValidASN(value)) {
errors.push({ field, message: `${field} должен быть валидным ASN` });
}
break;
case 'community':
if (!isValidCommunity(value)) {
errors.push({ field, message: `${field} должен быть валидным BGP Community` });
}
break;
case 'gateway':
if (!isValidGateway(value)) {
errors.push({ field, message: `${field} должен быть валидным gateway именем` });
}
break;
}
}
// Проверка минимальной длины
if (rules.minLength && String(value).length < rules.minLength) {
errors.push({ field, message: `${field} должен быть не короче ${rules.minLength} символов` });
}
// Проверка максимальной длины
if (rules.maxLength && String(value).length > rules.maxLength) {
errors.push({ field, message: `${field} должен быть не длиннее ${rules.maxLength} символов` });
}
// Проверка безопасности
if (rules.checkXSS && !isSafeXSSString(value)) {
errors.push({ field, message: `${field} содержит потенциально опасные символы` });
}
if (rules.checkSQL && !isSafeSQLString(value)) {
errors.push({ field, message: `${field} содержит недопустимые SQL конструкции` });
}
}
return {
valid: errors.length === 0,
errors
};
}
module.exports = {
isValidIPv4,
isValidIPv6,
isValidCIDRv4,
isValidCIDRv6,
isValidDomain,
isValidWildcardDomain,
isValidASN,
isValidCommunity,
isValidGateway,
sanitizeString,
isSafeSQLString,
isSafeXSSString,
validateData,
};