1446 lines
46 KiB
Markdown
1446 lines
46 KiB
Markdown
# 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)
|
||
```
|