diff --git a/prompt-opus-full-review-2026-05-30.md b/prompt-opus-full-review-2026-05-30.md new file mode 100644 index 0000000..bf3dcc2 --- /dev/null +++ b/prompt-opus-full-review-2026-05-30.md @@ -0,0 +1,1445 @@ +# 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) +```