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

46 KiB
Raw Blame History

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

-- IP WhiteList schema

CREATE TABLE IF NOT EXISTS companies (
    id            SERIAL PRIMARY KEY,
    client_id     VARCHAR(64) UNIQUE NOT NULL,
    name          VARCHAR(255),
    custom_limit  INTEGER DEFAULT NULL CHECK (custom_limit IS NULL OR custom_limit >= 0),
    created_at    TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at    TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE IF NOT EXISTS whitelist_entries (
    id           SERIAL PRIMARY KEY,
    company_id   INTEGER NOT NULL REFERENCES companies(id) ON DELETE RESTRICT,
    value_cidr   VARCHAR(18) NOT NULL CHECK (value_cidr ~ '^(\d{1,3}\.){3}\d{1,3}/\d{1,2}$'),
    comment      VARCHAR(255),
    created_by   VARCHAR(255) NOT NULL,
    created_at   TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_by   VARCHAR(255),
    updated_at   TIMESTAMPTZ,
    deleted_by   VARCHAR(255),
    deleted_at   TIMESTAMPTZ
);

-- Уникальность активного CIDR в рамках компании (защита от гонок дублей)
CREATE UNIQUE INDEX IF NOT EXISTS uq_entries_active_cidr
    ON whitelist_entries(company_id, value_cidr) WHERE deleted_at IS NULL;

-- Составной для списка активных записей компании
CREATE INDEX IF NOT EXISTS idx_entries_active
    ON whitelist_entries(company_id, created_at DESC) WHERE deleted_at IS NULL;

CREATE TABLE IF NOT EXISTS audit_log (
    id           SERIAL PRIMARY KEY,
    user_email   VARCHAR(255) NOT NULL,
    company_id   INTEGER NOT NULL,
    action       VARCHAR(32) NOT NULL CHECK (action IN ('CREATE','UPDATE','DELETE')),
    old_value    TEXT,
    new_value    TEXT,
    entry_id     INTEGER,
    created_at   TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX IF NOT EXISTS idx_audit_company_time ON audit_log(company_id, created_at DESC);

src/db.js

const { Pool } = require('pg');
require('dotenv').config();

const pool = new Pool({
  host: process.env.DB_HOST,
  port: process.env.DB_PORT || 5432,
  database: process.env.DB_NAME || 'postgres',
  user: process.env.DB_USER,
  password: process.env.DB_PASS,
  ssl: process.env.DB_SSLMODE === 'require' ? { rejectUnauthorized: false } : false,
  max: 10,
  idleTimeoutMillis: 30000,
});

pool.on('error', (err) => {
  console.error('DB pool error:', err.message);
});

async function checkConnection() {
  const { rows } = await pool.query('SELECT 1 AS ok');
  return rows[0].ok === 1;
}

module.exports = { pool, checkConnection };

src/config.js

'use strict';

const MOCK_USERS = [
  {
    id:          'admin',
    label:       'Администратор (WZ01112)',
    role:        'admin',
    clientId:    'WZ01112',
    companyId:   'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee',
    companyName: 'Nubes Admin',
    email:       'admin@nubes.ru',
  },
  {
    id:          'test',
    label:       'Тест (WZ01325)',
    role:        'user',
    clientId:    'WZ01325',
    companyId:   '3e64aac6-dcfc-4082-88dc-da19c86555a5',
    companyName: 'Тест',
    email:       'tazet@narod.ru',
  },
  {
    id:          'client2',
    label:       'Компания 2 (WZ02001)',
    role:        'user',
    clientId:    'WZ02001',
    companyId:   '11111111-2222-3333-4444-555555555555',
    companyName: 'Вторая Компания',
    email:       'user2@example.com',
  },
];

function backUrl(isAdmin, companyId, extra = {}) {
  const qs = new URLSearchParams();
  if (isAdmin && companyId) qs.set('company', companyId);
  for (const [k, v] of Object.entries(extra)) qs.set(k, String(v));
  const str = qs.toString();
  return str ? '/?' + str : '/';
}

module.exports = { MOCK_USERS, backUrl };

src/validators.js

const net = require('net');

const BLOCKED_RANGES = [
  '10.0.0.0/8',
  '172.16.0.0/12',
  '192.168.0.0/16',
  '100.64.0.0/10',
  '127.0.0.0/8',
  '169.254.0.0/16',
  '192.0.0.0/24',
  '192.0.2.0/24',
  '198.51.100.0/24',
  '203.0.113.0/24',
  '198.18.0.0/15',
  '224.0.0.0/4',
  '240.0.0.0/4',
  '255.255.255.255/32',
];

function validate(input) {
  const raw = (input || '').trim();
  if (!raw) throw new Error('Пустое значение');

  if (raw.includes(':')) throw new Error('IPv6 не поддерживается');
  if (/[a-zA-Z]/.test(raw.replace(/\./g, '').replace(/\//g, '').replace(/\d/g, '')))
    throw new Error('Некорректный формат');

  if ((raw.match(/\//g) || []).length > 1) {
    throw new Error('Некорректный формат');
  }

  let cidr = raw.includes('/') ? raw : raw + '/32';
  const [addr, maskStr] = cidr.split('/');

  if (!/^\d{1,2}$/.test(maskStr)) {
    throw new Error('Некорректная маска');
  }
  const mask = parseInt(maskStr, 10);

  if (mask < 22 || mask > 32) {
    throw new Error('Маска должна быть от /22 до /32');
  }

  if (!net.isIPv4(addr)) throw new Error('Некорректный IPv4 адрес');

  const ipNum = addr.split('.').reduce((acc, octet) => (acc << 8) + parseInt(octet, 10), 0) >>> 0;
  const netMask = ~((1 << (32 - mask)) - 1) >>> 0;
  const network = (ipNum & netMask) >>> 0;
  const networkAddr = [
    (network >>> 24) & 0xff,
    (network >>> 16) & 0xff,
    (network >>> 8) & 0xff,
    network & 0xff,
  ].join('.');

  const wasNormalized = addr !== networkAddr;
  const normalized = networkAddr + '/' + mask;

  for (const blocked of BLOCKED_RANGES) {
    if (overlaps(normalized, blocked)) {
      throw new Error(`Диапазон ${normalized} пересекается с запрещённым (${blocked})`);
    }
  }

  return { cidr: normalized, wasNormalized };
}

function overlaps(cidr1, cidr2) {
  const a = cidrToRange(cidr1);
  const b = cidrToRange(cidr2);
  return a.start <= b.end && b.start <= a.start ||
         b.start <= a.end && a.start <= b.start;
}

function isSubnetOf(cidr, parent) {
  const child = cidrToRange(cidr);
  const par = cidrToRange(parent);
  return child.start >= par.start && child.end <= par.end;
}

function cidrToRange(cidr) {
  const [addr, maskStr] = cidr.split('/');
  const mask = parseInt(maskStr, 10);
  const ip = addr.split('.').reduce((acc, o) => (acc << 8) + parseInt(o, 10), 0) >>> 0;
  const start = ip >>> 0;
  const end = (ip | ((1 << (32 - mask)) - 1)) >>> 0;
  return { start, end };
}

function numToIP(n) {
  return [
    (n >>> 24) & 0xff,
    (n >>> 16) & 0xff,
    (n >>> 8)  & 0xff,
    n & 0xff,
  ].join('.');
}

function rangeToCIDRs(start, end) {
  const cidrs = [];
  let cur = start >>> 0;
  const endU = end >>> 0;

  while (cur <= endU) {
    let trailingZeros = 0;
    if (cur === 0) {
      trailingZeros = 32;
    } else {
      let tmp = cur;
      while ((tmp & 1) === 0 && trailingZeros < 32) {
        tmp = tmp >>> 1;
        trailingZeros++;
      }
    }

    let prefix = 32 - trailingZeros;
    while (prefix <= 32) {
      const hostBits = 32 - prefix;
      const blockEnd = hostBits > 0 ? (cur + Math.pow(2, hostBits) - 1) >>> 0 : cur;
      if (blockEnd <= endU) break;
      prefix++;
    }

    cidrs.push(numToIP(cur) + '/' + prefix);

    const hostBits = 32 - prefix;
    if (hostBits === 0) {
      if (cur === 0xFFFFFFFF) break;
      cur = (cur + 1) >>> 0;
    } else {
      const blockSize = Math.pow(2, hostBits);
      cur = (cur + blockSize) >>> 0;
      if (cur === 0) break;
    }
  }

  return cidrs;
}

function aggregateCIDRs(cidrs) {
  if (!cidrs || cidrs.length === 0) return [];

  const ranges = cidrs.map(cidrToRange);

  ranges.sort((a, b) => {
    if (a.start < b.start) return -1;
    if (a.start > b.start) return 1;
    return 0;
  });

  const merged = [{ start: ranges[0].start, end: ranges[0].end }];
  for (let i = 1; i < ranges.length; i++) {
    const last = merged[merged.length - 1];
    const cur = ranges[i];
    const adjacent = last.end < 0xFFFFFFFF && cur.start === last.end + 1;
    if (cur.start <= last.end || adjacent) {
      if (cur.end > last.end) last.end = cur.end;
    } else {
      merged.push({ start: cur.start, end: cur.end });
    }
  }

  const result = [];
  for (const range of merged) {
    result.push(...rangeToCIDRs(range.start, range.end));
  }
  return result;
}

module.exports = { validate, overlaps, BLOCKED_RANGES, aggregateCIDRs };

src/queries.js

const { pool } = require('./db');
const { validate, overlaps } = require('./validators');

async function getOrCreateCompany(clientId, companyName) {
  const res = await pool.query(
    `INSERT INTO companies (client_id, name) VALUES ($1, $2)
     ON CONFLICT (client_id) DO UPDATE SET name = COALESCE(companies.name, EXCLUDED.name)
     RETURNING *`,
    [clientId, companyName || clientId]
  );
  return res.rows[0];
}

async function getLimit(company) {
  const defaultLimit = parseInt(process.env.DEFAULT_LIMIT, 10) || 15;
  return company.custom_limit != null ? company.custom_limit : defaultLimit;
}

async function listEntries(companyId, includeDeleted = false) {
  let sql = 'SELECT * FROM whitelist_entries WHERE company_id = $1';
  if (!includeDeleted) sql += ' AND deleted_at IS NULL';
  sql += ' ORDER BY created_at DESC';
  return (await pool.query(sql, [companyId])).rows;
}

async function createEntry(companyId, rawValue, comment, userEmail) {
  const { cidr, wasNormalized } = validate(rawValue);
  const client = await pool.connect();
  try {
    await client.query('BEGIN');
    const company = (await client.query(
      'SELECT * FROM companies WHERE id = $1 FOR UPDATE', [companyId]
    )).rows[0];
    if (!company) throw new Error('Компания не найдена');

    const limit = await getLimit(company);
    const cnt = (await client.query(
      'SELECT COUNT(*)::int AS c FROM whitelist_entries WHERE company_id = $1 AND deleted_at IS NULL',
      [companyId]
    )).rows[0].c;
    if (cnt >= limit) throw new Error(`Лимит исчерпан: ${cnt} из ${limit}`);

    const existing = (await client.query(
      'SELECT value_cidr FROM whitelist_entries WHERE company_id = $1 AND deleted_at IS NULL',
      [companyId]
    )).rows;
    for (const row of existing) {
      if (row.value_cidr === cidr) throw new Error('Такой адрес уже существует');
      if (overlaps(cidr, row.value_cidr))
        throw new Error(`Пересечение с существующей записью ${row.value_cidr}`);
    }

    const res = await client.query(
      `INSERT INTO whitelist_entries (company_id, value_cidr, comment, created_by)
       VALUES ($1, $2, $3, $4) RETURNING *`,
      [companyId, cidr, comment || null, userEmail]
    );
    await logAudit(userEmail, companyId, 'CREATE', null, cidr, res.rows[0].id, client);
    await client.query('COMMIT');
    return { entry: res.rows[0], wasNormalized };
  } catch (e) {
    await client.query('ROLLBACK');
    throw e;
  } finally {
    client.release();
  }
}

async function updateEntry(entryId, companyId, rawValue, comment, userEmail) {
  const { cidr, wasNormalized } = validate(rawValue);
  const client = await pool.connect();
  try {
    await client.query('BEGIN');
    await client.query('SELECT * FROM companies WHERE id = $1 FOR UPDATE', [companyId]);

    const old = (await client.query(
      'SELECT * FROM whitelist_entries WHERE id = $1 AND company_id = $2 AND deleted_at IS NULL',
      [entryId, companyId]
    )).rows[0];
    if (!old) throw new Error('Запись не найдена');

    const existing = (await client.query(
      'SELECT value_cidr FROM whitelist_entries WHERE company_id = $1 AND deleted_at IS NULL AND id <> $2',
      [companyId, entryId]
    )).rows;
    for (const row of existing) {
      if (row.value_cidr === cidr) throw new Error('Такой адрес уже существует');
      if (overlaps(cidr, row.value_cidr))
        throw new Error(`Пересечение с существующей записью ${row.value_cidr}`);
    }

    const res = await client.query(
      `UPDATE whitelist_entries SET value_cidr = $1, comment = $2, updated_by = $3, updated_at = NOW()
       WHERE id = $4 AND company_id = $5 RETURNING *`,
      [cidr, comment || old.comment, userEmail, entryId, companyId]
    );
    await logAudit(userEmail, companyId, 'UPDATE', old.value_cidr, cidr, entryId, client);
    await client.query('COMMIT');
    return { entry: res.rows[0], wasNormalized };
  } catch (e) {
    await client.query('ROLLBACK');
    throw e;
  } finally {
    client.release();
  }
}

async function deleteEntry(entryId, companyId, userEmail) {
  const client = await pool.connect();
  try {
    await client.query('BEGIN');

    const old = (await client.query(
      'SELECT * FROM whitelist_entries WHERE id = $1 AND company_id = $2 AND deleted_at IS NULL',
      [entryId, companyId]
    )).rows[0];
    if (!old) throw new Error('Запись не найдена');

    await client.query(
      'UPDATE whitelist_entries SET deleted_by = $1, deleted_at = NOW() WHERE id = $2 AND company_id = $3',
      [userEmail, entryId, companyId]
    );
    await logAudit(userEmail, companyId, 'DELETE', old.value_cidr, null, entryId, client);
    await client.query('COMMIT');
  } catch (e) {
    await client.query('ROLLBACK');
    throw e;
  } finally {
    client.release();
  }
}

async function getCompanyById(id) {
  const res = await pool.query('SELECT * FROM companies WHERE id = $1', [id]);
  return res.rows[0] || null;
}

async function getAllCompanies() {
  const res = await pool.query(`
    SELECT c.*,
           COUNT(e.id)::int AS active_count
      FROM companies c
      LEFT JOIN whitelist_entries e
             ON e.company_id = c.id AND e.deleted_at IS NULL
     GROUP BY c.id
     ORDER BY c.name, c.client_id
  `);
  return res.rows;
}

async function setLimit(companyId, newLimit) {
  await pool.query(
    'UPDATE companies SET custom_limit = $1, updated_at = NOW() WHERE id = $2',
    [newLimit, companyId]
  );
}

async function getExportCIDRs(companyId = null) {
  let sql = 'SELECT value_cidr FROM whitelist_entries WHERE deleted_at IS NULL';
  const params = [];
  if (companyId) {
    sql += ' AND company_id = $1';
    params.push(companyId);
  }
  sql += ' ORDER BY value_cidr';
  return ((await pool.query(sql, params)).rows).map(r => r.value_cidr);
}

async function logAudit(userEmail, companyId, action, oldValue, newValue, entryId, db = pool) {
  await db.query(
    `INSERT INTO audit_log (user_email, company_id, action, old_value, new_value, entry_id)
     VALUES ($1, $2, $3, $4, $5, $6)`,
    [userEmail, companyId, action, oldValue, newValue, entryId || null]
  );
}

async function getAudit(companyId = null) {
  let sql = `
    SELECT a.*, c.name AS company_name, c.client_id
      FROM audit_log a
      JOIN companies c ON c.id = a.company_id
  `;
  const params = [];
  if (companyId) {
    sql += ' WHERE a.company_id = $1';
    params.push(companyId);
  }
  sql += ' ORDER BY a.created_at DESC LIMIT 500';
  return (await pool.query(sql, params)).rows;
}

module.exports = {
  getOrCreateCompany, getLimit,
  listEntries, createEntry, updateEntry, deleteEntry,
  getExportCIDRs,
  getAudit,
  getCompanyById, getAllCompanies, setLimit,
};

src/middleware/rateLimit.js

'use strict';

const { rateLimit } = require('express-rate-limit');

const mutationLimiter = rateLimit({
  windowMs: 60_000,
  limit: 30,
  standardHeaders: 'draft-7',
  legacyHeaders: false,
  message: { error: 'Слишком много запросов. Подождите минуту.' },
});

const exportLimiter = rateLimit({
  windowMs: 60_000,
  limit: 20,
  standardHeaders: 'draft-7',
  legacyHeaders: false,
  message: 'Слишком много запросов к /export. Подождите минуту.',
});

module.exports = { mutationLimiter, exportLimiter };

src/middleware/csrf.js

'use strict';

const { doubleCsrf } = require('csrf-csrf');

function initCsrf() {
  const { doubleCsrfProtection, generateCsrfToken } = doubleCsrf({
    getSecret: () => process.env.CSRF_SECRET || 'dev-csrf-secret-change-in-prod',

    // Используем JWT-cookie как session identifier.
    // ВАЖНО: после перехода на express-session это устарело — cookie 'jwt' больше не выдаётся.
    // Нужно обновить на req.session?.id или req.sessionID.
    getSessionIdentifier: (req) => req.cookies.jwt || '',

    cookieOptions: {
      secure: process.env.NODE_ENV === 'production',
      sameSite: 'lax',
      httpOnly: true,
    },

    size: 64,
  });

  return { doubleCsrfProtection, generateCsrfToken };
}

module.exports = { initCsrf };

src/auth.js

'use strict';

const jwt    = require('jsonwebtoken');
const crypto = require('crypto');
const https  = require('https');
const http   = require('http');

const ISSUER           = process.env.JWT_ISSUER        || 'mock-auth-api';
const ADMIN_CLIENT_ID  = process.env.ADMIN_CLIENT_ID   || 'WZ01112';

const KC_BASE_URL      = (process.env.KC_BASE_URL || 'https://keycloak.nubes.ru/realms/cloud').replace(/\/$/, '');
const KC_CLIENT_ID     = process.env.KC_CLIENT_ID     || '';
const KC_CLIENT_SECRET = process.env.KC_CLIENT_SECRET || '';
const APP_URL          = (process.env.APP_URL || 'http://localhost:3000').replace(/\/$/, '');

const OIDC = {
  auth:   KC_BASE_URL + '/protocol/openid-connect/auth',
  token:  KC_BASE_URL + '/protocol/openid-connect/token',
  certs:  KC_BASE_URL + '/protocol/openid-connect/certs',
  logout: KC_BASE_URL + '/protocol/openid-connect/logout',
};

let mockKeyPair = null;
let mockJwks    = null;
let cachedJwks  = null;

function initAuth() {
  const devMode   = process.env.DEV_MODE   === 'true';
  const devSecret = process.env.DEV_SECRET || '';

  const isOidc = !!(KC_CLIENT_ID && KC_CLIENT_SECRET);

  if (isOidc) {
    console.log('[auth] OIDC — Keycloak', KC_BASE_URL);
  } else {
    console.log('[auth] MOCK — local RS256 key');
    mockKeyPair = crypto.generateKeyPairSync('rsa', {
      modulusLength: 2048,
      publicKeyEncoding:  { type: 'spki',  format: 'pem' },
      privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
    });
    const jwkPub = crypto.createPublicKey(mockKeyPair.publicKey).export({ format: 'jwk' });
    Object.assign(jwkPub, { alg: 'RS256', use: 'sig', kid: 'mock-1' });
    mockJwks = { keys: [jwkPub] };
  }

  if (devMode)   console.log('[auth] DEV_MODE — /dev-login доступен без пароля');
  if (devSecret) console.log('[auth] DEV_SECRET задан — /dev-login с ключом');

  return {
    isOidc,
    middleware:      createMiddleware(isOidc),
    buildAuthUrl,
    exchangeCode,
    buildLogoutUrl,
    issueMockToken,
    devLoginEnabled: devMode || !!devSecret,
    DEV_SECRET:      devSecret,
    jwksHandler: !isOidc ? (req, res) => res.json(mockJwks) : null,
  };
}

function createMiddleware(isOidc) {
  return function authMiddleware(req, res, next) {
    if (req.session && req.session.user) {
      req.user = req.session.user;
      return next();
    }

    const bearer = (req.headers.authorization || '').replace(/^Bearer\s+/i, '').trim();
    if (bearer) {
      try {
        const payload = isOidc ? verifyOidcToken(bearer) : verifyMockToken(bearer);
        req.user = userFromPayload(payload);
        return next();
      } catch (e) {
        return res.status(401).json({ error: 'Invalid token: ' + e.message });
      }
    }

    if (!isOidc && req.cookies && req.cookies.jwt) {
      try {
        const payload = verifyMockToken(req.cookies.jwt);
        req.user = userFromPayload(payload);
        if (req.session) {
          req.session.user = req.user;
          res.clearCookie('jwt');
        }
        return next();
      } catch (_) {
        res.clearCookie('jwt');
      }
    }

    if (req.method === 'GET') {
      return res.redirect('/login?returnTo=' + encodeURIComponent(req.originalUrl));
    }
    return res.status(401).send('Unauthorized');
  };
}

function buildAuthUrl(state) {
  const params = new URLSearchParams({
    response_type: 'code',
    client_id:     KC_CLIENT_ID,
    redirect_uri:  APP_URL + '/callback',
    scope:         'openid profile email',
    state,
  });
  return OIDC.auth + '?' + params;
}

async function exchangeCode(code) {
  const body = new URLSearchParams({
    grant_type:    'authorization_code',
    client_id:     KC_CLIENT_ID,
    client_secret: KC_CLIENT_SECRET,
    redirect_uri:  APP_URL + '/callback',
    code,
  }).toString();

  const raw  = await httpPost(OIDC.token, body, { 'Content-Type': 'application/x-www-form-urlencoded' });
  const data = JSON.parse(raw);
  if (!data.access_token) throw new Error('No access_token: ' + raw.slice(0, 200));

  if (!cachedJwks) cachedJwks = JSON.parse(await httpGet(OIDC.certs));
  const payload = verifyOidcToken(data.access_token);

  return {
    user:         userFromPayload(payload),
    idToken:      data.id_token      || null,
    refreshToken: data.refresh_token || null,
  };
}

function buildLogoutUrl(idToken) {
  const params = new URLSearchParams({ post_logout_redirect_uri: APP_URL + '/login' });
  if (idToken) params.set('id_token_hint', idToken);
  return OIDC.logout + '?' + params;
}

function issueMockToken(claims) {
  if (!mockKeyPair) throw new Error('Mock keypair not initialized');
  return jwt.sign(
    { ClientID: claims.clientId, company_id: claims.companyId, company_name: claims.companyName, email: claims.email, login: claims.email },
    mockKeyPair.privateKey,
    { algorithm: 'RS256', issuer: ISSUER, subject: claims.companyId || 'mock', expiresIn: '24h', keyid: 'mock-1' }
  );
}

function verifyMockToken(token) {
  if (!mockKeyPair) throw new Error('Mock keypair not initialized');
  return jwt.verify(token, mockKeyPair.publicKey, { algorithms: ['RS256'], issuer: ISSUER });
}

function verifyOidcToken(token) {
  if (!cachedJwks) throw new Error('JWKS not loaded yet');
  const key = cachedJwks.keys.find(k => k.kty === 'RSA' && k.use === 'sig');
  if (!key) throw new Error('No RSA sig key in JWKS');
  const pem = crypto.createPublicKey({ key, format: 'jwk' }).export({ type: 'spki', format: 'pem' });
  return jwt.verify(token, pem, { algorithms: ['RS256'] });
}

function userFromPayload(payload) {
  const clientId = payload.ClientID || payload.client_id || payload.preferred_username || 'UNKNOWN';
  return {
    email:       payload.email || payload.login || 'unknown',
    clientId,
    companyId:   payload.company_id || payload.sub || null,
    companyName: payload.company_name || clientId,
    isAdmin:     clientId === ADMIN_CLIENT_ID,
  };
}

function requireAdmin(req, res, next) {
  if (!req.user || !req.user.isAdmin) {
    return res.status(403).send('403 Forbidden: требуется роль администратора');
  }
  next();
}

function httpGet(url) {
  return new Promise((resolve, reject) => {
    const lib = url.startsWith('https') ? https : http;
    const req = lib.get(url, { timeout: 5000 }, (res) => {
      let data = '';
      res.on('data', c => { data += c; });
      res.on('end', () => res.statusCode === 200 ? resolve(data) : reject(new Error('HTTP ' + res.statusCode)));
    });
    req.on('error', reject);
    req.on('timeout', () => { req.destroy(); reject(new Error('Timeout')); });
  });
}

function httpPost(url, body, headers) {
  return new Promise((resolve, reject) => {
    const u   = new URL(url);
    const lib = u.protocol === 'https:' ? https : http;
    const opts = {
      hostname: u.hostname,
      port:     u.port || (u.protocol === 'https:' ? 443 : 80),
      path:     u.pathname + u.search,
      method:   'POST',
      headers:  { ...headers, 'Content-Length': Buffer.byteLength(body) },
      timeout:  5000,
    };
    const req = lib.request(opts, (res) => {
      let data = '';
      res.on('data', c => { data += c; });
      res.on('end', () => {
        if (res.statusCode >= 200 && res.statusCode < 300) resolve(data);
        else reject(new Error('HTTP ' + res.statusCode + ': ' + data.slice(0, 200)));
      });
    });
    req.on('error', reject);
    req.on('timeout', () => { req.destroy(); reject(new Error('Timeout')); });
    req.write(body);
    req.end();
  });
}

module.exports = { initAuth, requireAdmin };

src/routes/auth.js

'use strict';

const crypto     = require('crypto');
const { Router } = require('express');

function createRouter({ auth, doubleCsrfProtection, generateCsrfToken, MOCK_USERS }) {
  const router = Router();

  router.get('/login', (req, res) => {
    if (auth.isOidc) {
      const state = crypto.randomBytes(16).toString('hex');
      req.session.oidcState = state;
      req.session.returnTo  = req.query.returnTo || '/';
      return res.redirect(auth.buildAuthUrl(state));
    }

    const csrfToken = generateCsrfToken(req, res);
    res.render('login', {
      users:       MOCK_USERS,
      error:       req.query.error || null,
      csrfToken,
      devLoginUrl: auth.devLoginEnabled ? '/dev-login' : null,
    });
  });

  router.post('/login', doubleCsrfProtection, (req, res) => {
    if (auth.isOidc) return res.redirect('/login');

    const user = MOCK_USERS.find(u => u.id === req.body.user);
    if (!user) {
      return res.redirect('/login?error=' + encodeURIComponent('Пользователь не найден'));
    }

    req.session.user = {
      email:       user.email,
      clientId:    user.clientId,
      companyId:   user.companyId,
      companyName: user.companyName,
      isAdmin:     user.role === 'admin',
    };

    res.redirect(req.query.returnTo || '/');
  });

  router.get('/callback', async (req, res) => {
    if (!auth.isOidc) return res.redirect('/login');

    const { code, state, error } = req.query;

    if (error) {
      console.error('[auth] Keycloak error:', error, req.query.error_description || '');
      return res.redirect('/login?error=' + encodeURIComponent('Keycloak: ' + error));
    }

    if (!state || state !== req.session.oidcState) {
      return res.redirect('/login?error=' + encodeURIComponent('Недействительный state'));
    }
    delete req.session.oidcState;

    try {
      const { user, idToken, refreshToken } = await auth.exchangeCode(code);
      req.session.user         = user;
      req.session.idToken      = idToken;
      req.session.refreshToken = refreshToken;
      const returnTo = req.session.returnTo || '/';
      delete req.session.returnTo;
      res.redirect(returnTo);
    } catch (e) {
      console.error('[auth] exchangeCode error:', e.message);
      res.redirect('/login?error=' + encodeURIComponent('Ошибка авторизации: ' + e.message));
    }
  });

  router.get('/dev-login', (req, res) => {
    if (!auth.devLoginEnabled) return res.status(404).send('Not Found');

    if (auth.DEV_SECRET) {
      const keyOk = req.query.key === auth.DEV_SECRET || req.session.devKey === auth.DEV_SECRET;
      if (!keyOk) return res.status(403).send('403 Forbidden: неверный dev-ключ');
      req.session.devKey = auth.DEV_SECRET;
    }

    const csrfToken = generateCsrfToken(req, res);
    res.render('dev-login', {
      users:     MOCK_USERS,
      error:     req.query.error || null,
      csrfToken,
    });
  });

  router.post('/dev-login', doubleCsrfProtection, (req, res) => {
    if (!auth.devLoginEnabled) return res.status(404).send('Not Found');

    if (auth.DEV_SECRET && req.session.devKey !== auth.DEV_SECRET) {
      return res.status(403).send('403 Forbidden');
    }

    let user;

    if (req.body.preset) {
      const mockUser = MOCK_USERS.find(u => u.id === req.body.preset);
      if (!mockUser) {
        return res.redirect('/dev-login?error=' + encodeURIComponent('Пользователь не найден'));
      }
      user = {
        email:       mockUser.email,
        clientId:    mockUser.clientId,
        companyId:   mockUser.companyId,
        companyName: mockUser.companyName,
        isAdmin:     mockUser.role === 'admin',
      };
    } else {
      const clientId = (req.body.clientId || '').trim();
      if (!clientId) {
        return res.redirect('/dev-login?error=' + encodeURIComponent('ClientID обязателен'));
      }
      user = {
        email:       (req.body.email       || '').trim() || clientId + '@dev.local',
        clientId,
        companyId:   (req.body.companyId   || '').trim() || null,
        companyName: (req.body.companyName || '').trim() || clientId,
        isAdmin:     req.body.isAdmin === 'on',
      };
    }

    req.session.user = user;
    res.redirect('/');
  });

  router.get('/logout', (req, res) => {
    const idToken = req.session.idToken || null;
    const isOidc  = auth.isOidc;

    req.session.destroy(() => {
      res.clearCookie('connect.sid');
      res.clearCookie('jwt');

      if (isOidc && idToken) {
        return res.redirect(auth.buildLogoutUrl(idToken));
      }
      res.redirect('/login');
    });
  });

  return router;
}

module.exports = { createRouter };

src/routes/entries.js

'use strict';

const { Router } = require('express');

function createRouter({ q, doubleCsrfProtection, mutationLimiter, generateCsrfToken, backUrl }) {
  const router = Router();

  router.get('/', async (req, res) => {
    const { clientId, companyName, isAdmin } = req.user;
    const csrfToken = generateCsrfToken(req, res);

    try {
      if (isAdmin) {
        const companies = await q.getAllCompanies();
        const selectedId = req.query.company
          ? parseInt(req.query.company, 10)
          : (companies[0] ? companies[0].id : null);
        const selectedCompany = companies.find(c => c.id === selectedId) || companies[0] || null;
        const entries = selectedCompany ? await q.listEntries(selectedCompany.id) : [];
        const limit   = selectedCompany ? await q.getLimit(selectedCompany) : 0;

        return res.render('index', {
          entries, limit, used: entries.length,
          user: req.user, isAdmin: true,
          companies, selectedCompany,
          error: req.query.error || null,
          message: req.query.message || null,
          wasNormalized: req.query.wasNormalized === '1',
          csrfToken,
        });
      }

      const company = await q.getOrCreateCompany(clientId, companyName);
      const limit   = await q.getLimit(company);
      const entries = await q.listEntries(company.id);

      res.render('index', {
        entries, limit, used: entries.length,
        user: req.user, isAdmin: false,
        companies: null, selectedCompany: company,
        error: req.query.error || null,
        message: req.query.message || null,
        wasNormalized: req.query.wasNormalized === '1',
        csrfToken,
      });
    } catch (e) {
      console.error('GET / error:', e);
      res.render('index', {
        entries: [], limit: 15, used: 0,
        user: req.user, isAdmin,
        companies: null, selectedCompany: null,
        error: 'Ошибка загрузки данных: ' + e.message,
        message: null, wasNormalized: false, csrfToken,
      });
    }
  });

  router.post('/add', mutationLimiter, doubleCsrfProtection, async (req, res) => {
    const { value, comment } = req.body;
    const { clientId, companyName, email, isAdmin } = req.user;

    let company;
    try {
      if (isAdmin && req.body.company_id) {
        const id = parseInt(req.body.company_id, 10);
        if (!Number.isFinite(id) || id <= 0) throw new Error('Некорректный company_id');
        company = await q.getCompanyById(id);
        if (!company) throw new Error('Компания не найдена');
      } else {
        company = await q.getOrCreateCompany(clientId, companyName);
      }

      const result = await q.createEntry(company.id, value, comment, email);
      const msg = result.wasNormalized
        ? `Адрес нормализован в ${result.entry.value_cidr}`
        : 'Добавлено';

      res.redirect(backUrl(isAdmin, company.id, {
        message: msg,
        wasNormalized: result.wasNormalized ? '1' : '0',
      }));
    } catch (e) {
      const cid = company ? company.id : req.body.company_id;
      res.redirect(backUrl(isAdmin, cid, { error: e.message }));
    }
  });

  router.post('/edit/:id', mutationLimiter, doubleCsrfProtection, async (req, res) => {
    const { value, comment } = req.body;
    const { clientId, companyName, email, isAdmin } = req.user;
    const entryId = parseInt(req.params.id, 10);

    let company;
    try {
      if (isAdmin && req.body.company_id) {
        const id = parseInt(req.body.company_id, 10);
        if (!Number.isFinite(id) || id <= 0) throw new Error('Некорректный company_id');
        company = await q.getCompanyById(id);
        if (!company) throw new Error('Компания не найдена');
      } else {
        company = await q.getOrCreateCompany(clientId, companyName);
      }

      const result = await q.updateEntry(entryId, company.id, value, comment, email);
      const msg = result.wasNormalized
        ? `Адрес нормализован в ${result.entry.value_cidr}`
        : 'Сохранено';

      res.redirect(backUrl(isAdmin, company.id, {
        message: msg,
        wasNormalized: result.wasNormalized ? '1' : '0',
      }));
    } catch (e) {
      const cid = company ? company.id : req.body.company_id;
      res.redirect(backUrl(isAdmin, cid, { error: e.message }));
    }
  });

  router.post('/delete/:id', mutationLimiter, doubleCsrfProtection, async (req, res) => {
    const { clientId, companyName, email, isAdmin } = req.user;
    const entryId = parseInt(req.params.id, 10);

    let company;
    try {
      if (isAdmin && req.body.company_id) {
        const id = parseInt(req.body.company_id, 10);
        if (!Number.isFinite(id) || id <= 0) throw new Error('Некорректный company_id');
        company = await q.getCompanyById(id);
        if (!company) throw new Error('Компания не найдена');
      } else {
        company = await q.getOrCreateCompany(clientId, companyName);
      }

      await q.deleteEntry(entryId, company.id, email);
      res.redirect(backUrl(isAdmin, company.id, { message: 'Запись удалена' }));
    } catch (e) {
      const cid = company ? company.id : req.body.company_id;
      res.redirect(backUrl(isAdmin, cid, { error: e.message }));
    }
  });

  return router;
}

module.exports = { createRouter };

src/routes/admin.js

'use strict';

const { Router } = require('express');

function createRouter({ q, requireAdmin, doubleCsrfProtection, mutationLimiter, generateCsrfToken }) {
  const router = Router();

  router.get('/audit', requireAdmin, async (req, res) => {
    try {
      const companyId = req.query.company ? parseInt(req.query.company, 10) : null;
      const companies = await q.getAllCompanies();
      const rows = await q.getAudit(companyId || null);

      res.render('audit', {
        user: req.user,
        rows, companies,
        selectedCompanyId: companyId,
        error: req.query.error || null,
      });
    } catch (e) {
      console.error('GET /audit error:', e);
      res.render('audit', {
        user: req.user,
        rows: [], companies: [],
        selectedCompanyId: null,
        error: 'Ошибка загрузки: ' + e.message,
      });
    }
  });

  router.get('/admin', requireAdmin, async (req, res) => {
    try {
      const companies    = await q.getAllCompanies();
      const defaultLimit = parseInt(process.env.DEFAULT_LIMIT, 10) || 15;

      res.render('admin', {
        user: req.user,
        companies, defaultLimit,
        message:   req.query.message || null,
        error:     req.query.error   || null,
        csrfToken: generateCsrfToken(req, res),
      });
    } catch (e) {
      console.error('GET /admin error:', e);
      res.status(500).send('Ошибка: ' + e.message);
    }
  });

  router.post('/admin/limit/:companyId', requireAdmin, mutationLimiter, doubleCsrfProtection,
    async (req, res) => {
      const companyId = parseInt(req.params.companyId, 10);
      if (!Number.isFinite(companyId) || companyId <= 0) {
        return res.redirect('/admin?error=' + encodeURIComponent('Некорректный ID компании'));
      }

      try {
        const raw = req.body.limit;
        let newLimit;

        if (raw === '' || raw === null || raw === undefined) {
          newLimit = null;
        } else {
          newLimit = parseInt(raw, 10);
          if (!Number.isFinite(newLimit) || newLimit < 0) {
            throw new Error('Лимит должен быть целым числом ≥ 0, или пустым для сброса');
          }
        }

        await q.setLimit(companyId, newLimit);
        const label = newLimit === null ? 'сброшен на дефолт' : `установлен: ${newLimit}`;
        res.redirect('/admin?message=' + encodeURIComponent(`Лимит ${label}`));
      } catch (e) {
        res.redirect('/admin?error=' + encodeURIComponent(e.message));
      }
    }
  );

  return router;
}

module.exports = { createRouter };

src/routes/export.js

'use strict';

const { Router } = require('express');

function createRouter({ q, exportLimiter, aggregateCIDRs }) {
  const router = Router();

  router.get('/export', exportLimiter, async (req, res) => {
    try {
      const cidrs = await q.getExportCIDRs();
      const aggregated = aggregateCIDRs(cidrs);

      res.setHeader('Content-Type', 'text/plain; charset=utf-8');
      res.setHeader('Content-Disposition', 'inline; filename="whitelist.txt"');
      res.send(aggregated.join('\n') + (aggregated.length ? '\n' : ''));
    } catch (e) {
      console.error('Export error:', e);
      res.status(500).send('Export error');
    }
  });

  return router;
}

module.exports = { createRouter };

server.js

'use strict';

const express  = require('express');
const session  = require('express-session');
const cookieParser = require('cookie-parser');
const path     = require('path');
const helmet   = require('helmet');
require('dotenv').config();

const { checkConnection }  = require('./src/db');
const { initAuth, requireAdmin } = require('./src/auth');
const { aggregateCIDRs }   = require('./src/validators');
const q                    = require('./src/queries');
const { MOCK_USERS, backUrl } = require('./src/config');
const { mutationLimiter, exportLimiter } = require('./src/middleware/rateLimit');
const { initCsrf }         = require('./src/middleware/csrf');

const app  = express();
const PORT = process.env.PORT || 3000;

app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views'));

app.use(helmet({ contentSecurityPolicy: false }));

if (process.env.NODE_ENV === 'production') app.set('trust proxy', 1);

app.use(express.static(path.join(__dirname, 'public')));
app.use(express.urlencoded({ extended: true, limit: '32kb' }));
app.use(cookieParser());

async function start() {
  const auth = await initAuth();

  app.use(session({
    secret:            process.env.SESSION_SECRET || 'dev-session-secret-change-me',
    resave:            false,
    saveUninitialized: false,
    cookie: {
      httpOnly: true,
      secure:   process.env.NODE_ENV === 'production',
      sameSite: 'lax',
      maxAge:   8 * 60 * 60 * 1000,
    },
  }));

  const { doubleCsrfProtection, generateCsrfToken } = initCsrf();

  app.get('/healthz', (req, res) => res.send('OK'));

  if (auth.jwksHandler) app.get('/.well-known/jwks.json', auth.jwksHandler);

  app.use(require('./src/routes/export').createRouter({
    q, exportLimiter, aggregateCIDRs,
  }));

  app.use(require('./src/routes/auth').createRouter({
    auth, doubleCsrfProtection, generateCsrfToken, MOCK_USERS,
  }));

  app.use(auth.middleware);

  app.use(require('./src/routes/entries').createRouter({
    q, doubleCsrfProtection, mutationLimiter, generateCsrfToken, backUrl,
  }));

  app.use(require('./src/routes/admin').createRouter({
    q, requireAdmin, doubleCsrfProtection, mutationLimiter, generateCsrfToken,
  }));

  app.use((err, req, res, next) => {
    if (err && (err.status === 403 || err.code === 'EBADCSRFTOKEN')) {
      return res.status(403).send(
        '<p style="font-family:sans-serif;padding:2rem">' +
        'Недействительный CSRF-токен. ' +
        '<a href="javascript:history.back()">Вернитесь назад</a> и повторите действие.' +
        '</p>'
      );
    }
    console.error('Unhandled error:', err);
    res.status(500).send('Внутренняя ошибка сервера');
  });

  checkConnection()
    .then(() => console.log('DB connected'))
    .catch(e => console.error('DB not ready:', e.message));

  app.listen(PORT, () => console.log('Server on port ' + PORT));
}

start().catch(e => { console.error('Startup error:', e); process.exit(1); });

tests/run-tests.js (сокращённо — структура и секция auth)

'use strict';
// 50 тестов, запуск: node tests/run-tests.js, без БД

// Секции:
// 1. Загрузка всех модулей (11 модулей)
// 2. src/config.js — MOCK_USERS, backUrl
// 3. src/validators.js — validate, aggregateCIDRs, overlaps (30+ тест-кейсов)
// 4. src/auth.js — session middleware, requireAdmin

// Пример теста session middleware:
process.env.DEV_MODE = 'true';
process.env.ADMIN_CLIENT_ID = 'WZ01112';

const { initAuth, requireAdmin } = require('../src/auth');
const auth = initAuth();

// session user → req.user
const mockUser = { clientId: 'WZ01325', companyId: 'test-uuid', isAdmin: false, ... };
const req = { headers: {}, cookies: {}, session: { user: mockUser } };
let nextCalled = false;
auth.middleware(req, {}, () => { nextCalled = true; });
// PASS: nextCalled === true, req.user.clientId === 'WZ01325'

// нет auth → redirect
const reqNone = { headers: {}, cookies: {}, session: {}, method: 'GET', originalUrl: '/some-page' };
let redirectUrl = null;
auth.middleware(reqNone, { redirect: url => { redirectUrl = url; } }, () => {});
// PASS: redirectUrl.startsWith('/login')

// Итог: 50 PASS, 0 FAIL

Известные проблемы (нашёл сам, можешь подтвердить/опровергнуть)

  1. csrf.jsgetSessionIdentifier использует 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 в stagingDEV_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)