Files
ipwhitelist-app/prompt-opus-full-review-2026-05-30.md
T

1446 lines
46 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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(
'<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)
```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)
```