46 KiB
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.13helmet ^8.2— security headersexpress-rate-limit ^8.5— rate limitingcsrf-csrf ^4.0— double-submit cookie CSRFexpress-session ^1.19— сессииjsonwebtoken ^9.0— JWT (mock RS256 + будущий OIDC)dotenv ^16.4
Что нужно проверить и оценить
Обязательно:
- Безопасность — OWASP Top 10, SQL injection, XSS, CSRF, session fixation, insecure direct object reference, обход изоляции между компаниями
- Корректность бизнес-логики — лимиты, транзакции, конкурентные запросы, edge cases
- Архитектурные решения — что хорошо, что стоит переделать
- Качество кода — ошибки, запахи, неочевидные места
- Тесты — что покрыто, что не покрыто, что критично добавить
- Готовность к продакшену — что заблокирует выход в прод
Дополнительно (если видишь):
- Уязвимости в OIDC/session flow (src/auth.js, src/routes/auth.js)
- Корректность CIDR-агрегации (aggregateCIDRs в validators.js)
- Изоляция данных между компаниями в queries.js
- CSRF — корректность конфигурации csrf-csrf
Формат ответа: структурированный Markdown, с разделами по темам, с цитатами кода где нужно. Оценка по каждому блоку: ✅ хорошо / ⚠️ замечание / ❌ проблема.
ВЕСЬ КОД ПРОЕКТА
sql/schema.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
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
'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
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
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
'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
'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
'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
'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
'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
'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
'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
'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(
'<p style="font-family:sans-serif;padding:2rem">' +
'Недействительный CSRF-токен. ' +
'<a href="javascript:history.back()">Вернитесь назад</a> и повторите действие.' +
'</p>'
);
}
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)
'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
Известные проблемы (нашёл сам, можешь подтвердить/опровергнуть)
-
csrf.js —
getSessionIdentifierиспользуетreq.cookies.jwt, но после перехода на express-session этот cookie больше не выдаётся (только legacy migration). Нуженreq.sessionID. -
overlaps() в validators.js — логика:
a.start <= b.end && b.start <= a.start— выглядит подозрительно (должно бытьb.start <= a.end). Тесты проходят, но алгоритм стоит проверить. -
OIDC JWKS кеш —
cachedJwksкешируется навсегда, без TTL. При ротации KK-ключей потребуется перезапуск. -
dev-login в staging —
DEV_SECRETзащищает endpoint URL-параметром, но секрет хранится в URL (будет в логах nginx/browser history). -
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)