# Full Code Review — IP WhiteList App > **Задача для Claude Opus:** Провести полный анализ проекта по коду ниже. > **Сохрани результат** в файл `/home/naeel/ipwhitelist-app/research/opus-full-review-2026-05-30.md` — > это важно, используй инструмент создания файлов. --- ## Контекст проекта **Стек:** Node.js + Express 4 + EJS + PostgreSQL (`pg` pool) **Цель:** Веб-сервис для управления IP white-list компаний. Компании добавляют публичные IPv4/CIDR в список; список агрегируется и экспортируется для настройки оборудования. **Аудитория:** Используется клиентами облачного провайдера Nubes (РФ). Данные изолированы по company. **Авторизация:** Keycloak Authorization Code Flow (реализован, но не активирован — нет KC credentials). Сейчас работает в mock-режиме с `express-session`. **Ветка:** `sonnet`, 2026-05-30 14:43 **Зависимости:** - `express ^4.21`, `ejs ^3.1`, `pg ^8.13` - `helmet ^8.2` — security headers - `express-rate-limit ^8.5` — rate limiting - `csrf-csrf ^4.0` — double-submit cookie CSRF - `express-session ^1.19` — сессии - `jsonwebtoken ^9.0` — JWT (mock RS256 + будущий OIDC) - `dotenv ^16.4` --- ## Что нужно проверить и оценить **Обязательно:** 1. **Безопасность** — OWASP Top 10, SQL injection, XSS, CSRF, session fixation, insecure direct object reference, обход изоляции между компаниями 2. **Корректность бизнес-логики** — лимиты, транзакции, конкурентные запросы, edge cases 3. **Архитектурные решения** — что хорошо, что стоит переделать 4. **Качество кода** — ошибки, запахи, неочевидные места 5. **Тесты** — что покрыто, что не покрыто, что критично добавить 6. **Готовность к продакшену** — что заблокирует выход в прод **Дополнительно (если видишь):** - Уязвимости в OIDC/session flow (src/auth.js, src/routes/auth.js) - Корректность CIDR-агрегации (aggregateCIDRs в validators.js) - Изоляция данных между компаниями в queries.js - CSRF — корректность конфигурации csrf-csrf **Формат ответа:** структурированный Markdown, с разделами по темам, с цитатами кода где нужно. Оценка по каждому блоку: ✅ хорошо / ⚠️ замечание / ❌ проблема. --- ## ВЕСЬ КОД ПРОЕКТА ### sql/schema.sql ```sql -- IP WhiteList schema CREATE TABLE IF NOT EXISTS companies ( id SERIAL PRIMARY KEY, client_id VARCHAR(64) UNIQUE NOT NULL, name VARCHAR(255), custom_limit INTEGER DEFAULT NULL CHECK (custom_limit IS NULL OR custom_limit >= 0), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS whitelist_entries ( id SERIAL PRIMARY KEY, company_id INTEGER NOT NULL REFERENCES companies(id) ON DELETE RESTRICT, value_cidr VARCHAR(18) NOT NULL CHECK (value_cidr ~ '^(\d{1,3}\.){3}\d{1,3}/\d{1,2}$'), comment VARCHAR(255), created_by VARCHAR(255) NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_by VARCHAR(255), updated_at TIMESTAMPTZ, deleted_by VARCHAR(255), deleted_at TIMESTAMPTZ ); -- Уникальность активного CIDR в рамках компании (защита от гонок дублей) CREATE UNIQUE INDEX IF NOT EXISTS uq_entries_active_cidr ON whitelist_entries(company_id, value_cidr) WHERE deleted_at IS NULL; -- Составной для списка активных записей компании CREATE INDEX IF NOT EXISTS idx_entries_active ON whitelist_entries(company_id, created_at DESC) WHERE deleted_at IS NULL; CREATE TABLE IF NOT EXISTS audit_log ( id SERIAL PRIMARY KEY, user_email VARCHAR(255) NOT NULL, company_id INTEGER NOT NULL, action VARCHAR(32) NOT NULL CHECK (action IN ('CREATE','UPDATE','DELETE')), old_value TEXT, new_value TEXT, entry_id INTEGER, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_audit_company_time ON audit_log(company_id, created_at DESC); ``` --- ### src/db.js ```js const { Pool } = require('pg'); require('dotenv').config(); const pool = new Pool({ host: process.env.DB_HOST, port: process.env.DB_PORT || 5432, database: process.env.DB_NAME || 'postgres', user: process.env.DB_USER, password: process.env.DB_PASS, ssl: process.env.DB_SSLMODE === 'require' ? { rejectUnauthorized: false } : false, max: 10, idleTimeoutMillis: 30000, }); pool.on('error', (err) => { console.error('DB pool error:', err.message); }); async function checkConnection() { const { rows } = await pool.query('SELECT 1 AS ok'); return rows[0].ok === 1; } module.exports = { pool, checkConnection }; ``` --- ### src/config.js ```js 'use strict'; const MOCK_USERS = [ { id: 'admin', label: 'Администратор (WZ01112)', role: 'admin', clientId: 'WZ01112', companyId: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee', companyName: 'Nubes Admin', email: 'admin@nubes.ru', }, { id: 'test', label: 'Тест (WZ01325)', role: 'user', clientId: 'WZ01325', companyId: '3e64aac6-dcfc-4082-88dc-da19c86555a5', companyName: 'Тест', email: 'tazet@narod.ru', }, { id: 'client2', label: 'Компания 2 (WZ02001)', role: 'user', clientId: 'WZ02001', companyId: '11111111-2222-3333-4444-555555555555', companyName: 'Вторая Компания', email: 'user2@example.com', }, ]; function backUrl(isAdmin, companyId, extra = {}) { const qs = new URLSearchParams(); if (isAdmin && companyId) qs.set('company', companyId); for (const [k, v] of Object.entries(extra)) qs.set(k, String(v)); const str = qs.toString(); return str ? '/?' + str : '/'; } module.exports = { MOCK_USERS, backUrl }; ``` --- ### src/validators.js ```js const net = require('net'); const BLOCKED_RANGES = [ '10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16', '100.64.0.0/10', '127.0.0.0/8', '169.254.0.0/16', '192.0.0.0/24', '192.0.2.0/24', '198.51.100.0/24', '203.0.113.0/24', '198.18.0.0/15', '224.0.0.0/4', '240.0.0.0/4', '255.255.255.255/32', ]; function validate(input) { const raw = (input || '').trim(); if (!raw) throw new Error('Пустое значение'); if (raw.includes(':')) throw new Error('IPv6 не поддерживается'); if (/[a-zA-Z]/.test(raw.replace(/\./g, '').replace(/\//g, '').replace(/\d/g, ''))) throw new Error('Некорректный формат'); if ((raw.match(/\//g) || []).length > 1) { throw new Error('Некорректный формат'); } 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, octet) => (acc << 8) + parseInt(octet, 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 wasNormalized = addr !== networkAddr; const normalized = networkAddr + '/' + mask; for (const blocked of BLOCKED_RANGES) { if (overlaps(normalized, blocked)) { throw new Error(`Диапазон ${normalized} пересекается с запрещённым (${blocked})`); } } return { cidr: normalized, wasNormalized }; } function overlaps(cidr1, cidr2) { const a = cidrToRange(cidr1); const b = cidrToRange(cidr2); return a.start <= b.end && b.start <= a.start || b.start <= a.end && a.start <= b.start; } function isSubnetOf(cidr, parent) { const child = cidrToRange(cidr); const par = cidrToRange(parent); return child.start >= par.start && child.end <= par.end; } function cidrToRange(cidr) { const [addr, maskStr] = cidr.split('/'); const mask = parseInt(maskStr, 10); const ip = addr.split('.').reduce((acc, o) => (acc << 8) + parseInt(o, 10), 0) >>> 0; const start = ip >>> 0; const end = (ip | ((1 << (32 - mask)) - 1)) >>> 0; return { start, end }; } function numToIP(n) { return [ (n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff, ].join('.'); } function rangeToCIDRs(start, end) { const cidrs = []; let cur = start >>> 0; const endU = end >>> 0; while (cur <= endU) { let trailingZeros = 0; if (cur === 0) { trailingZeros = 32; } else { let tmp = cur; while ((tmp & 1) === 0 && trailingZeros < 32) { tmp = tmp >>> 1; trailingZeros++; } } let prefix = 32 - trailingZeros; while (prefix <= 32) { const hostBits = 32 - prefix; const blockEnd = hostBits > 0 ? (cur + Math.pow(2, hostBits) - 1) >>> 0 : cur; if (blockEnd <= endU) break; prefix++; } cidrs.push(numToIP(cur) + '/' + prefix); const hostBits = 32 - prefix; if (hostBits === 0) { if (cur === 0xFFFFFFFF) break; cur = (cur + 1) >>> 0; } else { const blockSize = Math.pow(2, hostBits); cur = (cur + blockSize) >>> 0; if (cur === 0) break; } } return cidrs; } function aggregateCIDRs(cidrs) { if (!cidrs || cidrs.length === 0) return []; const ranges = cidrs.map(cidrToRange); ranges.sort((a, b) => { if (a.start < b.start) return -1; if (a.start > b.start) return 1; return 0; }); const merged = [{ start: ranges[0].start, end: ranges[0].end }]; for (let i = 1; i < ranges.length; i++) { const last = merged[merged.length - 1]; const cur = ranges[i]; const adjacent = last.end < 0xFFFFFFFF && cur.start === last.end + 1; if (cur.start <= last.end || adjacent) { if (cur.end > last.end) last.end = cur.end; } else { merged.push({ start: cur.start, end: cur.end }); } } const result = []; for (const range of merged) { result.push(...rangeToCIDRs(range.start, range.end)); } return result; } module.exports = { validate, overlaps, BLOCKED_RANGES, aggregateCIDRs }; ``` --- ### src/queries.js ```js const { pool } = require('./db'); const { validate, overlaps } = require('./validators'); async function getOrCreateCompany(clientId, companyName) { const res = await pool.query( `INSERT INTO companies (client_id, name) VALUES ($1, $2) ON CONFLICT (client_id) DO UPDATE SET name = COALESCE(companies.name, EXCLUDED.name) RETURNING *`, [clientId, companyName || clientId] ); return res.rows[0]; } async function getLimit(company) { const defaultLimit = parseInt(process.env.DEFAULT_LIMIT, 10) || 15; return company.custom_limit != null ? company.custom_limit : defaultLimit; } async function listEntries(companyId, includeDeleted = false) { let sql = 'SELECT * FROM whitelist_entries WHERE company_id = $1'; if (!includeDeleted) sql += ' AND deleted_at IS NULL'; sql += ' ORDER BY created_at DESC'; return (await pool.query(sql, [companyId])).rows; } async function createEntry(companyId, rawValue, comment, userEmail) { const { cidr, wasNormalized } = validate(rawValue); const client = await pool.connect(); try { await client.query('BEGIN'); const company = (await client.query( 'SELECT * FROM companies WHERE id = $1 FOR UPDATE', [companyId] )).rows[0]; if (!company) throw new Error('Компания не найдена'); const limit = await getLimit(company); const cnt = (await client.query( 'SELECT COUNT(*)::int AS c FROM whitelist_entries WHERE company_id = $1 AND deleted_at IS NULL', [companyId] )).rows[0].c; if (cnt >= limit) throw new Error(`Лимит исчерпан: ${cnt} из ${limit}`); const existing = (await client.query( 'SELECT value_cidr FROM whitelist_entries WHERE company_id = $1 AND deleted_at IS NULL', [companyId] )).rows; for (const row of existing) { if (row.value_cidr === cidr) throw new Error('Такой адрес уже существует'); if (overlaps(cidr, row.value_cidr)) throw new Error(`Пересечение с существующей записью ${row.value_cidr}`); } const res = await client.query( `INSERT INTO whitelist_entries (company_id, value_cidr, comment, created_by) VALUES ($1, $2, $3, $4) RETURNING *`, [companyId, cidr, comment || null, userEmail] ); await logAudit(userEmail, companyId, 'CREATE', null, cidr, res.rows[0].id, client); await client.query('COMMIT'); return { entry: res.rows[0], wasNormalized }; } catch (e) { await client.query('ROLLBACK'); throw e; } finally { client.release(); } } async function updateEntry(entryId, companyId, rawValue, comment, userEmail) { const { cidr, wasNormalized } = validate(rawValue); const client = await pool.connect(); try { await client.query('BEGIN'); await client.query('SELECT * FROM companies WHERE id = $1 FOR UPDATE', [companyId]); const old = (await client.query( 'SELECT * FROM whitelist_entries WHERE id = $1 AND company_id = $2 AND deleted_at IS NULL', [entryId, companyId] )).rows[0]; if (!old) throw new Error('Запись не найдена'); const existing = (await client.query( 'SELECT value_cidr FROM whitelist_entries WHERE company_id = $1 AND deleted_at IS NULL AND id <> $2', [companyId, entryId] )).rows; for (const row of existing) { if (row.value_cidr === cidr) throw new Error('Такой адрес уже существует'); if (overlaps(cidr, row.value_cidr)) throw new Error(`Пересечение с существующей записью ${row.value_cidr}`); } const res = await client.query( `UPDATE whitelist_entries SET value_cidr = $1, comment = $2, updated_by = $3, updated_at = NOW() WHERE id = $4 AND company_id = $5 RETURNING *`, [cidr, comment || old.comment, userEmail, entryId, companyId] ); await logAudit(userEmail, companyId, 'UPDATE', old.value_cidr, cidr, entryId, client); await client.query('COMMIT'); return { entry: res.rows[0], wasNormalized }; } catch (e) { await client.query('ROLLBACK'); throw e; } finally { client.release(); } } async function deleteEntry(entryId, companyId, userEmail) { const client = await pool.connect(); try { await client.query('BEGIN'); const old = (await client.query( 'SELECT * FROM whitelist_entries WHERE id = $1 AND company_id = $2 AND deleted_at IS NULL', [entryId, companyId] )).rows[0]; if (!old) throw new Error('Запись не найдена'); await client.query( 'UPDATE whitelist_entries SET deleted_by = $1, deleted_at = NOW() WHERE id = $2 AND company_id = $3', [userEmail, entryId, companyId] ); await logAudit(userEmail, companyId, 'DELETE', old.value_cidr, null, entryId, client); await client.query('COMMIT'); } catch (e) { await client.query('ROLLBACK'); throw e; } finally { client.release(); } } async function getCompanyById(id) { const res = await pool.query('SELECT * FROM companies WHERE id = $1', [id]); return res.rows[0] || null; } async function getAllCompanies() { const res = await pool.query(` SELECT c.*, COUNT(e.id)::int AS active_count FROM companies c LEFT JOIN whitelist_entries e ON e.company_id = c.id AND e.deleted_at IS NULL GROUP BY c.id ORDER BY c.name, c.client_id `); return res.rows; } async function setLimit(companyId, newLimit) { await pool.query( 'UPDATE companies SET custom_limit = $1, updated_at = NOW() WHERE id = $2', [newLimit, companyId] ); } async function getExportCIDRs(companyId = null) { let sql = 'SELECT value_cidr FROM whitelist_entries WHERE deleted_at IS NULL'; const params = []; if (companyId) { sql += ' AND company_id = $1'; params.push(companyId); } sql += ' ORDER BY value_cidr'; return ((await pool.query(sql, params)).rows).map(r => r.value_cidr); } async function logAudit(userEmail, companyId, action, oldValue, newValue, entryId, db = pool) { await db.query( `INSERT INTO audit_log (user_email, company_id, action, old_value, new_value, entry_id) VALUES ($1, $2, $3, $4, $5, $6)`, [userEmail, companyId, action, oldValue, newValue, entryId || null] ); } async function getAudit(companyId = null) { let sql = ` SELECT a.*, c.name AS company_name, c.client_id FROM audit_log a JOIN companies c ON c.id = a.company_id `; const params = []; if (companyId) { sql += ' WHERE a.company_id = $1'; params.push(companyId); } sql += ' ORDER BY a.created_at DESC LIMIT 500'; return (await pool.query(sql, params)).rows; } module.exports = { getOrCreateCompany, getLimit, listEntries, createEntry, updateEntry, deleteEntry, getExportCIDRs, getAudit, getCompanyById, getAllCompanies, setLimit, }; ``` --- ### src/middleware/rateLimit.js ```js 'use strict'; const { rateLimit } = require('express-rate-limit'); const mutationLimiter = rateLimit({ windowMs: 60_000, limit: 30, standardHeaders: 'draft-7', legacyHeaders: false, message: { error: 'Слишком много запросов. Подождите минуту.' }, }); const exportLimiter = rateLimit({ windowMs: 60_000, limit: 20, standardHeaders: 'draft-7', legacyHeaders: false, message: 'Слишком много запросов к /export. Подождите минуту.', }); module.exports = { mutationLimiter, exportLimiter }; ``` --- ### src/middleware/csrf.js ```js 'use strict'; const { doubleCsrf } = require('csrf-csrf'); function initCsrf() { const { doubleCsrfProtection, generateCsrfToken } = doubleCsrf({ getSecret: () => process.env.CSRF_SECRET || 'dev-csrf-secret-change-in-prod', // Используем JWT-cookie как session identifier. // ВАЖНО: после перехода на express-session это устарело — cookie 'jwt' больше не выдаётся. // Нужно обновить на req.session?.id или req.sessionID. getSessionIdentifier: (req) => req.cookies.jwt || '', cookieOptions: { secure: process.env.NODE_ENV === 'production', sameSite: 'lax', httpOnly: true, }, size: 64, }); return { doubleCsrfProtection, generateCsrfToken }; } module.exports = { initCsrf }; ``` --- ### src/auth.js ```js 'use strict'; const jwt = require('jsonwebtoken'); const crypto = require('crypto'); const https = require('https'); const http = require('http'); const ISSUER = process.env.JWT_ISSUER || 'mock-auth-api'; const ADMIN_CLIENT_ID = process.env.ADMIN_CLIENT_ID || 'WZ01112'; const KC_BASE_URL = (process.env.KC_BASE_URL || 'https://keycloak.nubes.ru/realms/cloud').replace(/\/$/, ''); const KC_CLIENT_ID = process.env.KC_CLIENT_ID || ''; const KC_CLIENT_SECRET = process.env.KC_CLIENT_SECRET || ''; const APP_URL = (process.env.APP_URL || 'http://localhost:3000').replace(/\/$/, ''); const OIDC = { auth: KC_BASE_URL + '/protocol/openid-connect/auth', token: KC_BASE_URL + '/protocol/openid-connect/token', certs: KC_BASE_URL + '/protocol/openid-connect/certs', logout: KC_BASE_URL + '/protocol/openid-connect/logout', }; let mockKeyPair = null; let mockJwks = null; let cachedJwks = null; function initAuth() { const devMode = process.env.DEV_MODE === 'true'; const devSecret = process.env.DEV_SECRET || ''; const isOidc = !!(KC_CLIENT_ID && KC_CLIENT_SECRET); if (isOidc) { console.log('[auth] OIDC — Keycloak', KC_BASE_URL); } else { console.log('[auth] MOCK — local RS256 key'); mockKeyPair = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, }); const jwkPub = crypto.createPublicKey(mockKeyPair.publicKey).export({ format: 'jwk' }); Object.assign(jwkPub, { alg: 'RS256', use: 'sig', kid: 'mock-1' }); mockJwks = { keys: [jwkPub] }; } if (devMode) console.log('[auth] DEV_MODE — /dev-login доступен без пароля'); if (devSecret) console.log('[auth] DEV_SECRET задан — /dev-login с ключом'); return { isOidc, middleware: createMiddleware(isOidc), buildAuthUrl, exchangeCode, buildLogoutUrl, issueMockToken, devLoginEnabled: devMode || !!devSecret, DEV_SECRET: devSecret, jwksHandler: !isOidc ? (req, res) => res.json(mockJwks) : null, }; } function createMiddleware(isOidc) { return function authMiddleware(req, res, next) { if (req.session && req.session.user) { req.user = req.session.user; return next(); } const bearer = (req.headers.authorization || '').replace(/^Bearer\s+/i, '').trim(); if (bearer) { try { const payload = isOidc ? verifyOidcToken(bearer) : verifyMockToken(bearer); req.user = userFromPayload(payload); return next(); } catch (e) { return res.status(401).json({ error: 'Invalid token: ' + e.message }); } } if (!isOidc && req.cookies && req.cookies.jwt) { try { const payload = verifyMockToken(req.cookies.jwt); req.user = userFromPayload(payload); if (req.session) { req.session.user = req.user; res.clearCookie('jwt'); } return next(); } catch (_) { res.clearCookie('jwt'); } } if (req.method === 'GET') { return res.redirect('/login?returnTo=' + encodeURIComponent(req.originalUrl)); } return res.status(401).send('Unauthorized'); }; } function buildAuthUrl(state) { const params = new URLSearchParams({ response_type: 'code', client_id: KC_CLIENT_ID, redirect_uri: APP_URL + '/callback', scope: 'openid profile email', state, }); return OIDC.auth + '?' + params; } async function exchangeCode(code) { const body = new URLSearchParams({ grant_type: 'authorization_code', client_id: KC_CLIENT_ID, client_secret: KC_CLIENT_SECRET, redirect_uri: APP_URL + '/callback', code, }).toString(); const raw = await httpPost(OIDC.token, body, { 'Content-Type': 'application/x-www-form-urlencoded' }); const data = JSON.parse(raw); if (!data.access_token) throw new Error('No access_token: ' + raw.slice(0, 200)); if (!cachedJwks) cachedJwks = JSON.parse(await httpGet(OIDC.certs)); const payload = verifyOidcToken(data.access_token); return { user: userFromPayload(payload), idToken: data.id_token || null, refreshToken: data.refresh_token || null, }; } function buildLogoutUrl(idToken) { const params = new URLSearchParams({ post_logout_redirect_uri: APP_URL + '/login' }); if (idToken) params.set('id_token_hint', idToken); return OIDC.logout + '?' + params; } function issueMockToken(claims) { if (!mockKeyPair) throw new Error('Mock keypair not initialized'); return jwt.sign( { ClientID: claims.clientId, company_id: claims.companyId, company_name: claims.companyName, email: claims.email, login: claims.email }, mockKeyPair.privateKey, { algorithm: 'RS256', issuer: ISSUER, subject: claims.companyId || 'mock', expiresIn: '24h', keyid: 'mock-1' } ); } function verifyMockToken(token) { if (!mockKeyPair) throw new Error('Mock keypair not initialized'); return jwt.verify(token, mockKeyPair.publicKey, { algorithms: ['RS256'], issuer: ISSUER }); } function verifyOidcToken(token) { if (!cachedJwks) throw new Error('JWKS not loaded yet'); const key = cachedJwks.keys.find(k => k.kty === 'RSA' && k.use === 'sig'); if (!key) throw new Error('No RSA sig key in JWKS'); const pem = crypto.createPublicKey({ key, format: 'jwk' }).export({ type: 'spki', format: 'pem' }); return jwt.verify(token, pem, { algorithms: ['RS256'] }); } function userFromPayload(payload) { const clientId = payload.ClientID || payload.client_id || payload.preferred_username || 'UNKNOWN'; return { email: payload.email || payload.login || 'unknown', clientId, companyId: payload.company_id || payload.sub || null, companyName: payload.company_name || clientId, isAdmin: clientId === ADMIN_CLIENT_ID, }; } function requireAdmin(req, res, next) { if (!req.user || !req.user.isAdmin) { return res.status(403).send('403 Forbidden: требуется роль администратора'); } next(); } function httpGet(url) { return new Promise((resolve, reject) => { const lib = url.startsWith('https') ? https : http; const req = lib.get(url, { timeout: 5000 }, (res) => { let data = ''; res.on('data', c => { data += c; }); res.on('end', () => res.statusCode === 200 ? resolve(data) : reject(new Error('HTTP ' + res.statusCode))); }); req.on('error', reject); req.on('timeout', () => { req.destroy(); reject(new Error('Timeout')); }); }); } function httpPost(url, body, headers) { return new Promise((resolve, reject) => { const u = new URL(url); const lib = u.protocol === 'https:' ? https : http; const opts = { hostname: u.hostname, port: u.port || (u.protocol === 'https:' ? 443 : 80), path: u.pathname + u.search, method: 'POST', headers: { ...headers, 'Content-Length': Buffer.byteLength(body) }, timeout: 5000, }; const req = lib.request(opts, (res) => { let data = ''; res.on('data', c => { data += c; }); res.on('end', () => { if (res.statusCode >= 200 && res.statusCode < 300) resolve(data); else reject(new Error('HTTP ' + res.statusCode + ': ' + data.slice(0, 200))); }); }); req.on('error', reject); req.on('timeout', () => { req.destroy(); reject(new Error('Timeout')); }); req.write(body); req.end(); }); } module.exports = { initAuth, requireAdmin }; ``` --- ### src/routes/auth.js ```js 'use strict'; const crypto = require('crypto'); const { Router } = require('express'); function createRouter({ auth, doubleCsrfProtection, generateCsrfToken, MOCK_USERS }) { const router = Router(); router.get('/login', (req, res) => { if (auth.isOidc) { const state = crypto.randomBytes(16).toString('hex'); req.session.oidcState = state; req.session.returnTo = req.query.returnTo || '/'; return res.redirect(auth.buildAuthUrl(state)); } const csrfToken = generateCsrfToken(req, res); res.render('login', { users: MOCK_USERS, error: req.query.error || null, csrfToken, devLoginUrl: auth.devLoginEnabled ? '/dev-login' : null, }); }); router.post('/login', doubleCsrfProtection, (req, res) => { if (auth.isOidc) return res.redirect('/login'); const user = MOCK_USERS.find(u => u.id === req.body.user); if (!user) { return res.redirect('/login?error=' + encodeURIComponent('Пользователь не найден')); } req.session.user = { email: user.email, clientId: user.clientId, companyId: user.companyId, companyName: user.companyName, isAdmin: user.role === 'admin', }; res.redirect(req.query.returnTo || '/'); }); router.get('/callback', async (req, res) => { if (!auth.isOidc) return res.redirect('/login'); const { code, state, error } = req.query; if (error) { console.error('[auth] Keycloak error:', error, req.query.error_description || ''); return res.redirect('/login?error=' + encodeURIComponent('Keycloak: ' + error)); } if (!state || state !== req.session.oidcState) { return res.redirect('/login?error=' + encodeURIComponent('Недействительный state')); } delete req.session.oidcState; try { const { user, idToken, refreshToken } = await auth.exchangeCode(code); req.session.user = user; req.session.idToken = idToken; req.session.refreshToken = refreshToken; const returnTo = req.session.returnTo || '/'; delete req.session.returnTo; res.redirect(returnTo); } catch (e) { console.error('[auth] exchangeCode error:', e.message); res.redirect('/login?error=' + encodeURIComponent('Ошибка авторизации: ' + e.message)); } }); router.get('/dev-login', (req, res) => { if (!auth.devLoginEnabled) return res.status(404).send('Not Found'); if (auth.DEV_SECRET) { const keyOk = req.query.key === auth.DEV_SECRET || req.session.devKey === auth.DEV_SECRET; if (!keyOk) return res.status(403).send('403 Forbidden: неверный dev-ключ'); req.session.devKey = auth.DEV_SECRET; } const csrfToken = generateCsrfToken(req, res); res.render('dev-login', { users: MOCK_USERS, error: req.query.error || null, csrfToken, }); }); router.post('/dev-login', doubleCsrfProtection, (req, res) => { if (!auth.devLoginEnabled) return res.status(404).send('Not Found'); if (auth.DEV_SECRET && req.session.devKey !== auth.DEV_SECRET) { return res.status(403).send('403 Forbidden'); } let user; if (req.body.preset) { const mockUser = MOCK_USERS.find(u => u.id === req.body.preset); if (!mockUser) { return res.redirect('/dev-login?error=' + encodeURIComponent('Пользователь не найден')); } user = { email: mockUser.email, clientId: mockUser.clientId, companyId: mockUser.companyId, companyName: mockUser.companyName, isAdmin: mockUser.role === 'admin', }; } else { const clientId = (req.body.clientId || '').trim(); if (!clientId) { return res.redirect('/dev-login?error=' + encodeURIComponent('ClientID обязателен')); } user = { email: (req.body.email || '').trim() || clientId + '@dev.local', clientId, companyId: (req.body.companyId || '').trim() || null, companyName: (req.body.companyName || '').trim() || clientId, isAdmin: req.body.isAdmin === 'on', }; } req.session.user = user; res.redirect('/'); }); router.get('/logout', (req, res) => { const idToken = req.session.idToken || null; const isOidc = auth.isOidc; req.session.destroy(() => { res.clearCookie('connect.sid'); res.clearCookie('jwt'); if (isOidc && idToken) { return res.redirect(auth.buildLogoutUrl(idToken)); } res.redirect('/login'); }); }); return router; } module.exports = { createRouter }; ``` --- ### src/routes/entries.js ```js 'use strict'; const { Router } = require('express'); function createRouter({ q, doubleCsrfProtection, mutationLimiter, generateCsrfToken, backUrl }) { const router = Router(); router.get('/', async (req, res) => { const { clientId, companyName, isAdmin } = req.user; const csrfToken = generateCsrfToken(req, res); try { if (isAdmin) { const companies = await q.getAllCompanies(); const selectedId = req.query.company ? parseInt(req.query.company, 10) : (companies[0] ? companies[0].id : null); const selectedCompany = companies.find(c => c.id === selectedId) || companies[0] || null; const entries = selectedCompany ? await q.listEntries(selectedCompany.id) : []; const limit = selectedCompany ? await q.getLimit(selectedCompany) : 0; return res.render('index', { entries, limit, used: entries.length, user: req.user, isAdmin: true, companies, selectedCompany, error: req.query.error || null, message: req.query.message || null, wasNormalized: req.query.wasNormalized === '1', csrfToken, }); } const company = await q.getOrCreateCompany(clientId, companyName); const limit = await q.getLimit(company); const entries = await q.listEntries(company.id); res.render('index', { entries, limit, used: entries.length, user: req.user, isAdmin: false, companies: null, selectedCompany: company, error: req.query.error || null, message: req.query.message || null, wasNormalized: req.query.wasNormalized === '1', csrfToken, }); } catch (e) { console.error('GET / error:', e); res.render('index', { entries: [], limit: 15, used: 0, user: req.user, isAdmin, companies: null, selectedCompany: null, error: 'Ошибка загрузки данных: ' + e.message, message: null, wasNormalized: false, csrfToken, }); } }); router.post('/add', mutationLimiter, doubleCsrfProtection, async (req, res) => { const { value, comment } = req.body; const { clientId, companyName, email, isAdmin } = req.user; let company; try { if (isAdmin && req.body.company_id) { const id = parseInt(req.body.company_id, 10); if (!Number.isFinite(id) || id <= 0) throw new Error('Некорректный company_id'); company = await q.getCompanyById(id); if (!company) throw new Error('Компания не найдена'); } else { company = await q.getOrCreateCompany(clientId, companyName); } const result = await q.createEntry(company.id, value, comment, email); const msg = result.wasNormalized ? `Адрес нормализован в ${result.entry.value_cidr}` : 'Добавлено'; res.redirect(backUrl(isAdmin, company.id, { message: msg, wasNormalized: result.wasNormalized ? '1' : '0', })); } catch (e) { const cid = company ? company.id : req.body.company_id; res.redirect(backUrl(isAdmin, cid, { error: e.message })); } }); router.post('/edit/:id', mutationLimiter, doubleCsrfProtection, async (req, res) => { const { value, comment } = req.body; const { clientId, companyName, email, isAdmin } = req.user; const entryId = parseInt(req.params.id, 10); let company; try { if (isAdmin && req.body.company_id) { const id = parseInt(req.body.company_id, 10); if (!Number.isFinite(id) || id <= 0) throw new Error('Некорректный company_id'); company = await q.getCompanyById(id); if (!company) throw new Error('Компания не найдена'); } else { company = await q.getOrCreateCompany(clientId, companyName); } const result = await q.updateEntry(entryId, company.id, value, comment, email); const msg = result.wasNormalized ? `Адрес нормализован в ${result.entry.value_cidr}` : 'Сохранено'; res.redirect(backUrl(isAdmin, company.id, { message: msg, wasNormalized: result.wasNormalized ? '1' : '0', })); } catch (e) { const cid = company ? company.id : req.body.company_id; res.redirect(backUrl(isAdmin, cid, { error: e.message })); } }); router.post('/delete/:id', mutationLimiter, doubleCsrfProtection, async (req, res) => { const { clientId, companyName, email, isAdmin } = req.user; const entryId = parseInt(req.params.id, 10); let company; try { if (isAdmin && req.body.company_id) { const id = parseInt(req.body.company_id, 10); if (!Number.isFinite(id) || id <= 0) throw new Error('Некорректный company_id'); company = await q.getCompanyById(id); if (!company) throw new Error('Компания не найдена'); } else { company = await q.getOrCreateCompany(clientId, companyName); } await q.deleteEntry(entryId, company.id, email); res.redirect(backUrl(isAdmin, company.id, { message: 'Запись удалена' })); } catch (e) { const cid = company ? company.id : req.body.company_id; res.redirect(backUrl(isAdmin, cid, { error: e.message })); } }); return router; } module.exports = { createRouter }; ``` --- ### src/routes/admin.js ```js 'use strict'; const { Router } = require('express'); function createRouter({ q, requireAdmin, doubleCsrfProtection, mutationLimiter, generateCsrfToken }) { const router = Router(); router.get('/audit', requireAdmin, async (req, res) => { try { const companyId = req.query.company ? parseInt(req.query.company, 10) : null; const companies = await q.getAllCompanies(); const rows = await q.getAudit(companyId || null); res.render('audit', { user: req.user, rows, companies, selectedCompanyId: companyId, error: req.query.error || null, }); } catch (e) { console.error('GET /audit error:', e); res.render('audit', { user: req.user, rows: [], companies: [], selectedCompanyId: null, error: 'Ошибка загрузки: ' + e.message, }); } }); router.get('/admin', requireAdmin, async (req, res) => { try { const companies = await q.getAllCompanies(); const defaultLimit = parseInt(process.env.DEFAULT_LIMIT, 10) || 15; res.render('admin', { user: req.user, companies, defaultLimit, message: req.query.message || null, error: req.query.error || null, csrfToken: generateCsrfToken(req, res), }); } catch (e) { console.error('GET /admin error:', e); res.status(500).send('Ошибка: ' + e.message); } }); router.post('/admin/limit/:companyId', requireAdmin, mutationLimiter, doubleCsrfProtection, async (req, res) => { const companyId = parseInt(req.params.companyId, 10); if (!Number.isFinite(companyId) || companyId <= 0) { return res.redirect('/admin?error=' + encodeURIComponent('Некорректный ID компании')); } try { const raw = req.body.limit; let newLimit; if (raw === '' || raw === null || raw === undefined) { newLimit = null; } else { newLimit = parseInt(raw, 10); if (!Number.isFinite(newLimit) || newLimit < 0) { throw new Error('Лимит должен быть целым числом ≥ 0, или пустым для сброса'); } } await q.setLimit(companyId, newLimit); const label = newLimit === null ? 'сброшен на дефолт' : `установлен: ${newLimit}`; res.redirect('/admin?message=' + encodeURIComponent(`Лимит ${label}`)); } catch (e) { res.redirect('/admin?error=' + encodeURIComponent(e.message)); } } ); return router; } module.exports = { createRouter }; ``` --- ### src/routes/export.js ```js 'use strict'; const { Router } = require('express'); function createRouter({ q, exportLimiter, aggregateCIDRs }) { const router = Router(); router.get('/export', exportLimiter, async (req, res) => { try { const cidrs = await q.getExportCIDRs(); const aggregated = aggregateCIDRs(cidrs); res.setHeader('Content-Type', 'text/plain; charset=utf-8'); res.setHeader('Content-Disposition', 'inline; filename="whitelist.txt"'); res.send(aggregated.join('\n') + (aggregated.length ? '\n' : '')); } catch (e) { console.error('Export error:', e); res.status(500).send('Export error'); } }); return router; } module.exports = { createRouter }; ``` --- ### server.js ```js 'use strict'; const express = require('express'); const session = require('express-session'); const cookieParser = require('cookie-parser'); const path = require('path'); const helmet = require('helmet'); require('dotenv').config(); const { checkConnection } = require('./src/db'); const { initAuth, requireAdmin } = require('./src/auth'); const { aggregateCIDRs } = require('./src/validators'); const q = require('./src/queries'); const { MOCK_USERS, backUrl } = require('./src/config'); const { mutationLimiter, exportLimiter } = require('./src/middleware/rateLimit'); const { initCsrf } = require('./src/middleware/csrf'); const app = express(); const PORT = process.env.PORT || 3000; app.set('view engine', 'ejs'); app.set('views', path.join(__dirname, 'views')); app.use(helmet({ contentSecurityPolicy: false })); if (process.env.NODE_ENV === 'production') app.set('trust proxy', 1); app.use(express.static(path.join(__dirname, 'public'))); app.use(express.urlencoded({ extended: true, limit: '32kb' })); app.use(cookieParser()); async function start() { const auth = await initAuth(); app.use(session({ secret: process.env.SESSION_SECRET || 'dev-session-secret-change-me', resave: false, saveUninitialized: false, cookie: { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', maxAge: 8 * 60 * 60 * 1000, }, })); const { doubleCsrfProtection, generateCsrfToken } = initCsrf(); app.get('/healthz', (req, res) => res.send('OK')); if (auth.jwksHandler) app.get('/.well-known/jwks.json', auth.jwksHandler); app.use(require('./src/routes/export').createRouter({ q, exportLimiter, aggregateCIDRs, })); app.use(require('./src/routes/auth').createRouter({ auth, doubleCsrfProtection, generateCsrfToken, MOCK_USERS, })); app.use(auth.middleware); app.use(require('./src/routes/entries').createRouter({ q, doubleCsrfProtection, mutationLimiter, generateCsrfToken, backUrl, })); app.use(require('./src/routes/admin').createRouter({ q, requireAdmin, doubleCsrfProtection, mutationLimiter, generateCsrfToken, })); app.use((err, req, res, next) => { if (err && (err.status === 403 || err.code === 'EBADCSRFTOKEN')) { return res.status(403).send( '
' + 'Недействительный CSRF-токен. ' + 'Вернитесь назад и повторите действие.' + '
' ); } console.error('Unhandled error:', err); res.status(500).send('Внутренняя ошибка сервера'); }); checkConnection() .then(() => console.log('DB connected')) .catch(e => console.error('DB not ready:', e.message)); app.listen(PORT, () => console.log('Server on port ' + PORT)); } start().catch(e => { console.error('Startup error:', e); process.exit(1); }); ``` --- ### tests/run-tests.js (сокращённо — структура и секция auth) ```js 'use strict'; // 50 тестов, запуск: node tests/run-tests.js, без БД // Секции: // 1. Загрузка всех модулей (11 модулей) // 2. src/config.js — MOCK_USERS, backUrl // 3. src/validators.js — validate, aggregateCIDRs, overlaps (30+ тест-кейсов) // 4. src/auth.js — session middleware, requireAdmin // Пример теста session middleware: process.env.DEV_MODE = 'true'; process.env.ADMIN_CLIENT_ID = 'WZ01112'; const { initAuth, requireAdmin } = require('../src/auth'); const auth = initAuth(); // session user → req.user const mockUser = { clientId: 'WZ01325', companyId: 'test-uuid', isAdmin: false, ... }; const req = { headers: {}, cookies: {}, session: { user: mockUser } }; let nextCalled = false; auth.middleware(req, {}, () => { nextCalled = true; }); // PASS: nextCalled === true, req.user.clientId === 'WZ01325' // нет auth → redirect const reqNone = { headers: {}, cookies: {}, session: {}, method: 'GET', originalUrl: '/some-page' }; let redirectUrl = null; auth.middleware(reqNone, { redirect: url => { redirectUrl = url; } }, () => {}); // PASS: redirectUrl.startsWith('/login') // Итог: 50 PASS, 0 FAIL ``` --- ## Известные проблемы (нашёл сам, можешь подтвердить/опровергнуть) 1. **csrf.js** — `getSessionIdentifier` использует `req.cookies.jwt`, но после перехода на express-session этот cookie больше не выдаётся (только legacy migration). Нужен `req.sessionID`. 2. **overlaps()** в validators.js — логика: `a.start <= b.end && b.start <= a.start` — выглядит подозрительно (должно быть `b.start <= a.end`). Тесты проходят, но алгоритм стоит проверить. 3. **OIDC JWKS кеш** — `cachedJwks` кешируется навсегда, без TTL. При ротации KK-ключей потребуется перезапуск. 4. **dev-login в staging** — `DEV_SECRET` защищает endpoint URL-параметром, но секрет хранится в URL (будет в логах nginx/browser history). 5. **Session в multi-pod** — MemoryStore не шарится между репликами. --- ## Куда сохранить результат Сохрани полный анализ в файл: `/home/naeel/ipwhitelist-app/research/opus-full-review-2026-05-30.md` Структура файла: ``` # Full Code Review — IP WhiteList (Opus, 2026-05-30) ## Итоговая оценка ## 1. Безопасность ## 2. Бизнес-логика и корректность ## 3. Архитектура ## 4. Качество кода ## 5. Тесты ## 6. Готовность к продакшену ## 7. Приоритизированный список замечаний (P1/P2/P3) ```