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

' + - 'Недействительный CSRF-токен. ' + - 'Вернитесь назад и повторите действие.' + - '

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

' + - 'Недействительный CSRF-токен. ' + - 'Вернитесь назад и повторите действие.' + - '

' - ); - } - console.error('Unhandled error:', err); - res.status(500).send('Внутренняя ошибка сервера'); - }); - - checkConnection() - .then(() => console.log('DB connected')) - .catch(e => console.error('DB not ready:', e.message)); - - app.listen(PORT, () => console.log('Server on port ' + PORT)); -} - -start().catch(e => { console.error('Startup error:', e); process.exit(1); }); -``` - ---- - -### tests/run-tests.js (сокращённо — структура и секция auth) - -```js -'use strict'; -// 50 тестов, запуск: node tests/run-tests.js, без БД - -// Секции: -// 1. Загрузка всех модулей (11 модулей) -// 2. src/config.js — MOCK_USERS, backUrl -// 3. src/validators.js — validate, aggregateCIDRs, overlaps (30+ тест-кейсов) -// 4. src/auth.js — session middleware, requireAdmin - -// Пример теста session middleware: -process.env.DEV_MODE = 'true'; -process.env.ADMIN_CLIENT_ID = 'WZ01112'; - -const { initAuth, requireAdmin } = require('../src/auth'); -const auth = initAuth(); - -// session user → req.user -const mockUser = { clientId: 'WZ01325', companyId: 'test-uuid', isAdmin: false, ... }; -const req = { headers: {}, cookies: {}, session: { user: mockUser } }; -let nextCalled = false; -auth.middleware(req, {}, () => { nextCalled = true; }); -// PASS: nextCalled === true, req.user.clientId === 'WZ01325' - -// нет auth → redirect -const reqNone = { headers: {}, cookies: {}, session: {}, method: 'GET', originalUrl: '/some-page' }; -let redirectUrl = null; -auth.middleware(reqNone, { redirect: url => { redirectUrl = url; } }, () => {}); -// PASS: redirectUrl.startsWith('/login') - -// Итог: 50 PASS, 0 FAIL -``` - ---- - -## Известные проблемы (нашёл сам, можешь подтвердить/опровергнуть) - -1. **csrf.js** — `getSessionIdentifier` использует `req.cookies.jwt`, но после перехода на express-session этот cookie больше не выдаётся (только legacy migration). Нужен `req.sessionID`. - -2. **overlaps()** в validators.js — логика: `a.start <= b.end && b.start <= a.start` — выглядит подозрительно (должно быть `b.start <= a.end`). Тесты проходят, но алгоритм стоит проверить. - -3. **OIDC JWKS кеш** — `cachedJwks` кешируется навсегда, без TTL. При ротации KK-ключей потребуется перезапуск. - -4. **dev-login в staging** — `DEV_SECRET` защищает endpoint URL-параметром, но секрет хранится в URL (будет в логах nginx/browser history). - -5. **Session в multi-pod** — MemoryStore не шарится между репликами. - ---- - -## Куда сохранить результат - -Сохрани полный анализ в файл: -`/home/naeel/IPWhiteList/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) -``` diff --git a/research/prompt-opus-queries.md b/research/prompt-opus-queries.md deleted file mode 100644 index b9d53a3..0000000 --- a/research/prompt-opus-queries.md +++ /dev/null @@ -1,168 +0,0 @@ -# Промпт для Claude Opus 4 — гонки, транзакции, безопасность в queries.js - -## Контекст (не анализируй) -Node.js + Express + PostgreSQL (pg pool). Микросервис IP WhiteList. Клиенты создают до 15 доверенных IPv4/CIDR. Многоарендность (изоляция по company_id). Аудит всех изменений обязателен. - -## Что нужно -Ниже полный код `queries.js`. Найди: -- Race conditions (TOCTOU между проверкой лимита и INSERT) -- Отсутствие транзакций там где они нужны -- SQL-инъекции -- Ошибки изоляции (может ли пользователь компании А затронуть записи компании Б?) -- Проблемы с audit_log (пишется ли при ошибках?) - -Ограничения: -- Не предлагай менять стек -- Только конкретные строки с исправлениями -- ВЕСЬ ОТВЕТ ОДНИМ БЛОКОМ — без свёрток, без интерактивных элементов, без ссылок. Чистый текст. - -```js -const { pool } = require('./db'); -const { validate, overlaps } = require('./validators'); - -// ── Companies ── - -async function getOrCreateCompany(clientId, companyName) { - let res = await pool.query('SELECT * FROM companies WHERE client_id = $1', [clientId]); - if (res.rows.length > 0) return res.rows[0]; - - res = await pool.query( - 'INSERT INTO companies (client_id, name) VALUES ($1, $2) 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 || defaultLimit; -} - -// ── Entries ── - -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 company = (await pool.query('SELECT * FROM companies WHERE id = $1', [companyId])).rows[0]; - const limit = await getLimit(company); - const cnt = (await pool.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 pool.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 pool.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); - - return { entry: res.rows[0], wasNormalized }; -} - -async function updateEntry(entryId, companyId, rawValue, comment, userEmail) { - const old = (await pool.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 { cidr, wasNormalized } = validate(rawValue); - - const existing = (await pool.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 pool.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); - - return { entry: res.rows[0], wasNormalized }; -} - -async function deleteEntry(entryId, companyId, userEmail) { - const old = (await pool.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 pool.query( - 'UPDATE whitelist_entries SET deleted_by = $1, deleted_at = NOW() WHERE id = $2', - [userEmail, entryId] - ); - - await logAudit(userEmail, companyId, 'DELETE', old.value_cidr, null, entryId); -} - -// ── Export ── - -async function getExportCIDRs() { - const rows = (await pool.query( - 'SELECT value_cidr FROM whitelist_entries WHERE deleted_at IS NULL ORDER BY value_cidr' - )).rows; - return rows.map(r => r.value_cidr); -} - -// ── Audit ── - -async function logAudit(userEmail, companyId, action, oldValue, newValue, entryId) { - await pool.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 * FROM audit_log'; - const params = []; - if (companyId) { - sql += ' WHERE company_id = $1'; - params.push(companyId); - } - sql += ' ORDER BY created_at DESC LIMIT 500'; - return (await pool.query(sql, params)).rows; -} - -module.exports = { - getOrCreateCompany, getLimit, - listEntries, createEntry, updateEntry, deleteEntry, - getExportCIDRs, - getAudit, -}; -``` diff --git a/research/prompt-opus-schema.md b/research/prompt-opus-schema.md deleted file mode 100644 index 0186f07..0000000 --- a/research/prompt-opus-schema.md +++ /dev/null @@ -1,59 +0,0 @@ -# Промпт для Claude Opus 4 — ревью схемы БД - -## Контекст (не анализируй) -PostgreSQL. Микросервис IP WhiteList. Многоарендность: у каждой компании (companies) свои записи (whitelist_entries). Лимит по умолчанию 15 активных записей на компанию, custom_limit переопределяет. Soft delete. Аудит всех изменений (audit_log). Сервис на Node.js + pg pool. - -## Что нужно -Ниже `schema.sql`. Найди: -- Отсутствующие индексы (полный скан таблиц под нагрузкой) -- Отсутствующие уникальные constraint'ы (дубликаты на уровне БД, не только в коде) -- Проблемы с внешними ключами (каскадное удаление?) -- Неоптимальные типы данных -- Уязвимости в структуре (можно ли обойти изоляцию через БД?) -- Что добавить для production - -Ограничения: -- Только конкретные DDL-строки -- ВЕСЬ ОТВЕТ ОДНИМ БЛОКОМ — без свёрток, без интерактивных элементов - -```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, - 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), - value_cidr VARCHAR(18) NOT NULL, - 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 -); - -CREATE INDEX IF NOT EXISTS idx_entries_active - ON whitelist_entries(company_id, value_cidr) 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, - old_value TEXT, - new_value TEXT, - entry_id INTEGER, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -CREATE INDEX IF NOT EXISTS idx_audit_company ON audit_log(company_id); -``` diff --git a/research/prompt-opus-server.md b/research/prompt-opus-server.md deleted file mode 100644 index 17d7c07..0000000 --- a/research/prompt-opus-server.md +++ /dev/null @@ -1,124 +0,0 @@ -# Промпт для Claude Opus 4 — безопасность server.js (auth, CSRF, XSS) - -## Контекст (не анализируй) -Node.js + Express + EJS + PostgreSQL. Микросервис IP WhiteList для облачного провайдера. Аутентификация через внешний auth-api (JWT в Authorization header). Сейчас DEV_MODE=true — временная заглушка. Продакшен с реальными токенами. - -## Что нужно -Ниже полный код `server.js`. Найди: -- Баги в auth middleware (обход при DEV_MODE, пустой req.user) -- CSRF-уязвимости (POST-формы без токенов) -- XSS через query-параметры (error в редиректе) -- Race conditions на уровне роутов -- Проблемы обработки ошибок -- Небезопасные HTTP-заголовки -- Отсутствующие middleware (helmet, rate-limit, etc.) -- Несоответствия ТЗ (где обещано, но не реализовано) - -Ограничения: -- Не предлагай менять стек -- Только конкретные строки и исправления -- ВЕСЬ ОТВЕТ ОДНИМ БЛОКОМ — без свёрток, без интерактивных элементов. Чистый текст. - -```js -const express = require('express'); -const path = require('path'); -require('dotenv').config(); -const { checkConnection } = require('./src/db'); -const q = require('./src/queries'); - -const app = express(); -const PORT = process.env.PORT || 3000; -const DEV = process.env.DEV_MODE === 'true'; - -app.set('view engine', 'ejs'); -app.set('views', path.join(__dirname, 'views')); -app.use(express.static(path.join(__dirname, 'public'))); -app.use(express.urlencoded({ extended: true })); - -// ── Auth middleware ── -app.use((req, res, next) => { - if (DEV) { - req.user = { email: 'dev@test.local', clientId: 'WZ01325', companyId: null, companyName: 'DEV' }; - return next(); - } - const auth = req.headers.authorization || ''; - try { - const payload = JSON.parse(Buffer.from(auth.replace('Bearer ', '').split('.')[1], 'base64').toString()); - req.user = { - email: payload.email || 'unknown', - clientId: payload.ClientID, - companyId: payload.company_id, - companyName: payload.company_name || payload.ClientID, - }; - } catch { req.user = {}; } - next(); -}); - -// ── Health ── -app.get('/healthz', (req, res) => res.send('OK')); - -// ── Главная ── -app.get('/', async (req, res) => { - const { clientId, companyName } = req.user; - try { - 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, error: null, message: null, wasNormalized: false }); - } catch (e) { - res.render('index', { entries: [], limit: 15, used: 0, user: req.user, error: e.message, message: null, wasNormalized: false }); - } -}); - -// ── Создать ── -app.post('/add', async (req, res) => { - const { value, comment } = req.body; - const { clientId, companyName, email } = req.user; - try { - const company = await q.getOrCreateCompany(clientId, companyName); - const result = await q.createEntry(company.id, value, comment, email); - const limit = await q.getLimit(company); - const entries = await q.listEntries(company.id); - res.render('index', { - entries, limit, used: entries.length, user: req.user, - message: result.wasNormalized ? `Адрес нормализован в ${result.entry.value_cidr}` : 'Добавлено', - error: null, wasNormalized: result.wasNormalized, - }); - } catch (e) { - const company = await q.getOrCreateCompany(clientId, companyName).catch(() => null); - const entries = company ? await q.listEntries(company.id).catch(() => []) : []; - const limit = company ? await q.getLimit(company).catch(() => 15) : 15; - res.render('index', { entries, limit, used: entries.length, user: req.user, error: e.message, message: null, wasNormalized: false }); - } -}); - -// ── Удалить (soft) ── -app.post('/delete/:id', async (req, res) => { - const { clientId, companyName, email } = req.user; - try { - const company = await q.getOrCreateCompany(clientId, companyName); - await q.deleteEntry(req.params.id, company.id, email); - res.redirect('/'); - } catch (e) { - res.redirect('/?error=' + encodeURIComponent(e.message)); - } -}); - -// ── Экспорт ── -app.get('/export', async (req, res) => { - try { - const cidrs = await q.getExportCIDRs(); - res.setHeader('Content-Type', 'text/plain; charset=utf-8'); - res.send(cidrs.join('\n') + '\n'); - } catch (e) { - res.status(500).send('Export error'); - } -}); - -// ── Старт ── -checkConnection() - .then(() => console.log('DB connected')) - .catch(e => console.error('DB not ready:', e.message)); - -app.listen(PORT, () => console.log(`Server on port ${PORT}`)); -``` diff --git a/research/prompt-opus-validators.md b/research/prompt-opus-validators.md deleted file mode 100644 index 4c6e542..0000000 --- a/research/prompt-opus-validators.md +++ /dev/null @@ -1,107 +0,0 @@ -# Промпт для Claude Opus 4 — код-ревью validators.js - -## Контекст (кратко, не анализируй — просто знай) - -Делаем микросервис IP WhiteList для облачного провайдера. Клиенты управляют доверенными IPv4-адресами через веб-интерфейс. Стек: Node.js + Express + EJS + PostgreSQL. - -Требования ТЗ к валидатору: -- Только IPv4, маска /22–/32 -- Нормализация host-битов (203.0.113.10/24 → 203.0.113.0/24), пользователь должен знать о нормализации -- Запрещены диапазоны: RFC1918 (10/8, 172.16/12, 192.168/16), CGNAT (100.64/10), Loopback (127/8), Link-local (169.254/16), IANA special (192.0.0/24), TEST-NET (192.0.2/24, 198.51.100/24, 203.0.113/24), Benchmarking (198.18/15), Multicast (224/4), Reserved (240/4), Limited broadcast (255.255.255.255/32) -- Проверка пересечений внутри компании, дубликатов, запрет вложенных подсетей - -## Что нужно - -Ниже код `validators.js`. Твоя задача — найти баги, уязвимости, несоответствия ТЗ и предложить исправления. - -Ограничения: -- Не предлагай сменить язык/стек/фреймворк -- Не пиши «общие рекомендации» — только конкретные места с номерами строк -- Если предлагаешь исправить — напиши точный новый код -- Если багов нет — так и скажи -- **ВЕСЬ ОТВЕТ ДОЛЖЕН БЫТЬ В ОДНОМ БЛОКЕ — ОДИН markdown-блок, без интерактивных элементов, без свёрток, без ссылок на файлы. Чистый текст, готовый к копированию одной операцией.** - -Файл: - -```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('Некорректный формат'); - - let cidr = raw.includes('/') ? raw : raw + '/32'; - const [addr, maskStr] = cidr.split('/'); - const mask = parseInt(maskStr, 10); - - if (isNaN(mask) || 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 (isSubnetOf(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 }; -} - -module.exports = { validate, overlaps, BLOCKED_RANGES }; -```