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

358 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Full Code Review — IP WhiteList (Opus, 2026-05-30)
> Ревью по коду из `prompt-opus-full-review-2026-05-30.md` (ветка `sonnet`).
> Легенда: ✅ хорошо · ⚠️ замечание · ❌ проблема (блокер/риск).
---
## 0. Краткое резюме (TL;DR)
Проект аккуратно структурирован: фабрики роутеров с DI, транзакции с `FOR UPDATE`,
параметризованные запросы, частичные уникальные индексы для защиты от гонок.
Базовая гигиена SQL/изоляции компаний — на хорошем уровне.
Однако к продакшену проект **не готов**. Найдено несколько серьёзных проблем:
| # | Проблема | Severity |
|---|----------|----------|
| 1 | `/export` смонтирован **до** auth-middleware → публичная выгрузка CIDR **всех** компаний | ❌ Критично |
| 2 | CSRF `getSessionIdentifier` читает несуществующую cookie `jwt` → токен не привязан к сессии | ❌ Критично |
| 3 | Нет `session.regenerate()` при логине → session fixation | ❌ Высокий |
| 4 | Open redirect через `returnTo` | ❌ Высокий |
| 5 | OIDC: нет проверки `issuer`/`audience`, нет matching по `kid`, JWKS не рефрешится и null в bearer-режиме | ❌ Высокий |
| 6 | `ssl: { rejectUnauthorized: false }` к БД | ⚠️/❌ |
| 7 | Дефолтные секреты (`SESSION_SECRET`, `CSRF_SECRET`) не fail-fast в проде | ⚠️ |
| 8 | CSP отключён (`contentSecurityPolicy: false`) | ⚠️ |
| 9 | Нет интеграционных тестов (auth, CSRF, IDOR, export) | ⚠️ |
---
## 1. Безопасность (OWASP Top 10)
### 1.1 ❌ Публичная выгрузка всех компаний через `/export`
В `server.js` порядок монтирования:
```js
app.use(require('./src/routes/export').createRouter({ q, exportLimiter, aggregateCIDRs }));
// ...
app.use(auth.middleware); // ← аутентификация ПОСЛЕ export
```
`/export` доступен **без аутентификации**, а внутри:
```js
const cidrs = await q.getExportCIDRs(); // companyId = null → ВСЕ компании
const aggregated = aggregateCIDRs(cidrs);
```
`getExportCIDRs(null)` возвращает CIDR **всех** компаний, агрегированные вместе.
Любой анонимный пользователь получает полный список whitelisted-IP всех арендаторов.
Это нарушение изоляции данных (A01 Broken Access Control) и утечка информации (A01/A04).
**Рекомендация:** одно из:
- перенести `/export` **после** `auth.middleware` и фильтровать по `req.user` (для админа — все/выбранная компания, для пользователя — только своя);
- либо, если выгрузка для оборудования должна быть машинной, защитить статическим bearer-токеном/mTLS и **никогда** не отдавать срез всех компаний без явной авторизации.
### 1.2 ❌ CSRF: `getSessionIdentifier` привязан к мёртвой cookie
```js
// src/middleware/csrf.js
getSessionIdentifier: (req) => req.cookies.jwt || '',
```
В коде есть честный комментарий, что после перехода на `express-session` cookie `jwt`
больше не выдаётся. Значит идентификатор сессии для **всех** пользователей = `''`.
Double-submit перестаёт быть привязан к конкретной сессии — токен валиден «глобально»,
что ослабляет защиту (особенно с учётом session fixation ниже).
**Рекомендация:**
```js
getSessionIdentifier: (req) => req.session?.id || req.sessionID || '',
```
и убедиться, что `initCsrf()` вызывается после подключения `session` middleware (сейчас так и есть).
### 1.3 ❌ Session fixation — нет регенерации сессии при логине
В `routes/auth.js` (POST `/login`, `/callback`, `/dev-login`) сразу пишется
`req.session.user = ...` без `req.session.regenerate()`. Идентификатор сессии,
выданный до аутентификации, сохраняется — классический session fixation (A07).
**Рекомендация:** перед установкой `user` вызывать:
```js
req.session.regenerate(err => { if (err) ...; req.session.user = user; req.session.save(() => res.redirect(...)); });
```
### 1.4 ❌ Open redirect через `returnTo`
```js
// POST /login
res.redirect(req.query.returnTo || '/');
// /callback
const returnTo = req.session.returnTo || '/';
res.redirect(returnTo);
```
`returnTo` приходит из запроса и не валидируется. Значение вида `//evil.com`
или `https://evil.com` приведёт к открытому редиректу (A01, фишинг).
**Рекомендация:** разрешать только локальные пути:
```js
function safeReturn(t) {
return (typeof t === 'string' && t.startsWith('/') && !t.startsWith('//')) ? t : '/';
}
```
### 1.5 ❌ OIDC verification — недостаточная проверка токена
```js
function verifyOidcToken(token) {
const key = cachedJwks.keys.find(k => k.kty === 'RSA' && k.use === 'sig'); // ← не по kid
...
return jwt.verify(token, pem, { algorithms: ['RS256'] }); // ← нет issuer/audience
}
```
Проблемы:
- **Нет проверки `issuer` и `audience`.** Любой RS256-токен, подписанный ключом из этого JWKS
(например, токен, выданный другому клиенту того же realm), пройдёт проверку → privilege/tenant confusion.
- **Выбор ключа не по `kid`** из заголовка токена, а «первый RSA sig». При ротации/нескольких
ключах возможны как ложные отказы, так и приём не того ключа.
- **`cachedJwks` загружается только в `exchangeCode`** и больше не рефрешится. В bearer-режиме
(запрос с `Authorization: Bearer` без предварительного `/callback`) `cachedJwks === null`
`verifyOidcToken` бросит `JWKS not loaded yet`. При ротации ключей в KC — отказы до рестарта.
**Рекомендация:** грузить JWKS при старте и кэшировать с TTL/refresh по `kid`; в `jwt.verify`
передавать `{ issuer: KC_ISSUER, audience: KC_CLIENT_ID, algorithms: ['RS256'] }`; выбирать ключ
по `kid` из декодированного заголовка.
### 1.6 ⚠️ TLS к БД отключает проверку сертификата
```js
ssl: process.env.DB_SSLMODE === 'require' ? { rejectUnauthorized: false } : false,
```
`rejectUnauthorized: false` = шифрование без аутентификации сервера → MITM возможен (A02/A05).
Имя `require` обманчиво: это поведение `sslmode=require` в libpq, но для прод-окружения
нужен `verify-full` с CA.
**Рекомендация:** добавить режим с CA: `{ ca: fs.readFileSync(DB_CA), rejectUnauthorized: true }`.
### 1.7 ⚠️ Дефолтные секреты не приводят к отказу в проде
```js
secret: process.env.SESSION_SECRET || 'dev-session-secret-change-me',
getSecret: () => process.env.CSRF_SECRET || 'dev-csrf-secret-change-in-prod',
```
Если переменные не заданы в проде — приложение молча стартует со слабыми предсказуемыми
секретами (A02/A05). Подделка сессионных cookie/CSRF становится тривиальной.
**Рекомендация:** при `NODE_ENV === 'production'` — fail-fast, если секреты не заданы/равны дефолту.
### 1.8 ⚠️ CSP отключён
```js
app.use(helmet({ contentSecurityPolicy: false }));
```
Отключённая CSP убирает важный слой защиты от XSS (A03). EJS-шаблоны в промпте не приведены —
**нельзя подтвердить**, что пользовательский ввод (`comment`, `companyName`, сообщения `error`/`message`
из query) экранируется через `<%= %>`, а не `<%- %>`. `error`/`message` берутся прямо из `req.query`
и рендерятся — при `<%- %>` это reflected XSS.
**Рекомендация:** включить разумную CSP; проверить, что все вывод-точки используют экранирование `<%= %>`.
### 1.9 ⚠️ `dev-login` — риск в проде
`/dev-login` при `DEV_MODE=true` (или заданном `DEV_SECRET`) позволяет войти под любым
пользователем, включая `isAdmin: on`, без пароля. Если `DEV_MODE` случайно окажется `true` в проде —
полный обход аутентификации.
**Рекомендация:** жёстко запретить `DEV_MODE` при `NODE_ENV=production` (отказ старта),
а не полагаться на конфигурацию окружения.
### 1.10 ⚠️ Нет rate-limit на логин
POST `/login`, `/dev-login`, `/callback` не покрыты лимитером — для mock некритично,
но при реальном OIDC `/callback` без лимита может использоваться для нагрузки на токен-эндпоинт KC.
### 1.11 ✅ Что сделано хорошо
- **SQL injection** — все запросы параметризованы (`$1, $2, ...`), конкатенации пользовательского
ввода в SQL нет. ✅
- **IDOR / изоляция компаний** — обычный пользователь не может передать `company_id`; для него всегда
`getOrCreateCompany(clientId, ...)` по его собственному `clientId` из токена. Все мутации (`createEntry`,
`updateEntry`, `deleteEntry`) фильтруют по `company_id`, а `getCompanyById` доступен только в админ-ветке. ✅
- **CSRF-обработчик** ошибок (`EBADCSRFTOKEN`) даёт понятный 403. ✅
- Cookie-флаги `httpOnly`, `secure` (в проде), `sameSite: 'lax'`. ✅
---
## 2. Корректность бизнес-логики, транзакции, конкурентность
### 2.1 ✅ Гонки при добавлении/лимиты
`createEntry` берёт `SELECT ... FOR UPDATE` по строке компании, затем считает count и
проверяет пересечения внутри одной транзакции. Это сериализует параллельные вставки в рамках
одной компании. Плюс частичный уникальный индекс `uq_entries_active_cidr` страхует от дублей
на уровне БД. Хорошая многоуровневая защита. ✅
### 2.2 ⚠️ Проверка пересечений O(n) перебором в приложении
`createEntry`/`updateEntry` загружают все активные CIDR и сравнивают через `overlaps` в JS.
При лимите ~15 записей это незаметно, но логика дублируется и проверка пересечений невозможна
на уровне БД (индекс ловит только точный дубль, не overlap). Для текущих лимитов — приемлемо. ⚠️
### 2.3 ⚠️ `updateEntry`: `comment || old.comment`
Пустая строка комментария (`''`) трактуется как «не менять» и возвращает старый комментарий —
пользователь не сможет очистить комментарий. Edge case. ⚠️
### 2.4 ⚠️ `value_cidr VARCHAR(18)` и regex в CHECK
Схема ограничивает `/\d{1,2}/` для маски, но приложение разрешает только `/22``/32`
согласовано. Однако CHECK-regex в БД допускает невалидные октеты (`999.999.999.999/40`),
полагаясь полностью на валидацию приложения. Дубль-валидация на уровне БД неполная. ⚠️
### 2.5 ⚠️ `companyId` (UUID) из токена фактически не используется
Для обычного пользователя доступ к данным идёт по `companies.id` (SERIAL), полученному из
`getOrCreateCompany(clientId)`. UUID `company_id` из токена в выборках не участвует. Это не баг
(изоляция по `clientId` корректна), но источник путаницы: два разных идентификатора компании. ⚠️
### 2.6 ✅ Аудит в той же транзакции
`logAudit(..., client)` выполняется внутри транзакции мутации — запись аудита атомарна
с изменением. ✅
---
## 3. CIDR-валидация и агрегация (`validators.js`)
### 3.1 ✅ `validate()`
- IPv6 отбрасывается, проверка формата, нормализация к адресу сети, проверка против
`BLOCKED_RANGES`. Логика корректна для /22–/32.
- Битовые операции `(acc << 8) + parseInt(...)` дают знаковое 32-битное промежуточное значение,
но финальный `>>> 0` приводит к беззнаковому — для рассматриваемых масок результат верный. ✅
### 3.2 ⚠️ `overlaps()` — корректно, но нечитаемо
```js
return a.start <= b.end && b.start <= a.start ||
b.start <= a.end && a.start <= b.start;
```
Сводится к «начало одного интервала лежит внутри другого» — это **корректный** критерий
пересечения двух интервалов (проверено на граничных случаях: вложенность, смежность, непересечение).
Но запись через смешанные `&&`/`||` без скобок хрупкая и трудна для ревью.
**Рекомендация:** заменить на каноническое `a.start <= b.end && b.start <= a.end`.
### 3.3 ⚠️ Список `BLOCKED_RANGES` неполон
Заблокированы RFC1918/CGNAT/loopback/link-local/multicast/reserved, но **не** `0.0.0.0/8`
(«this network»). Можно добавить, например, `0.0.0.0/22`. Маловажно, но для строгого whitelist стоит закрыть.
### 3.4 ✅ `aggregateCIDRs()` / `rangeToCIDRs()`
- Сортировка по `start`, слияние перекрывающихся и **смежных** диапазонов (с защитой от переполнения
`last.end < 0xFFFFFFFF`), затем разбиение объединённого диапазона на минимальный набор выровненных CIDR.
- `rangeToCIDRs` корректно выбирает наибольший выровненный блок (`trailingZeros`) и уменьшает префикс,
пока блок не помещается в диапазон; курсор всегда продвигается → бесконечного цикла нет, граница
`0xFFFFFFFF` обработана. ✅
Алгоритмически — самая сильная часть проекта.
---
## 4. Архитектура и качество кода
### 4.1 ✅ Сильные стороны
- **Фабрики роутеров с DI** (`createRouter({...})`) — тестируемо, явные зависимости, без скрытых импортов состояния.
- **Разделение слоёв**: `db` / `queries` / `validators` / `routes` / `middleware`.
- **Транзакции** с корректным `BEGIN/COMMIT/ROLLBACK` и `finally { client.release() }`.
- **Auth-абстракция** поддерживает и mock-RS256, и реальный OIDC за единым интерфейсом.
### 4.2 ⚠️ Замечания
- **Дублирование** обработки `company_id` в трёх хендлерах `entries.js` (add/edit/delete) — почти
идентичный блок «определить компанию». Можно вынести в helper-middleware `resolveCompany`.
- **Обработка ошибок через redirect c `error` в query** удобна для UI, но смешивает 4xx-валидацию
и 5xx-сбои БД (любая ошибка `createEntry` уезжает в `?error=...`). Стоит различать пользовательские
ошибки и системные (логировать stack для последних).
- `cachedJwks` / `mockKeyPair` — модульное состояние; для горизонтального масштабирования mock-JWKS
у каждого инстанса свой ключ → токены не валидны между подами. Для mock-режима ок, но в проде
mock использоваться не должен.
- **`express-session` MemoryStore** (стор не задан) — утечки памяти и потеря сессий при рестарте/масштабировании.
Для прода нужен внешний стор (Redis/PG). ⚠️ (фактически блокер прода)
- Комментарии-TODO прямо в коде (`csrf.js`, `db.js`) — хорошо, что зафиксированы, но это незакрытый долг.
---
## 5. Тесты
Из промпта видно ~50 юнит-тестов без БД: загрузка модулей, `config`, `validators` (30+ кейсов),
`auth` (session middleware, `requireAdmin`).
### ✅ Покрыто
- Валидаторы CIDR / агрегация / overlaps — основной риск-домен покрыт хорошо.
- Session-middleware happy path и `requireAdmin`.
### ❌ Не покрыто (критично добавить)
1. **Публичность `/export`** — тест, что неаутентифицированный запрос **не** получает данные
(после фикса 1.1). Сейчас регрессия не отлавливается.
2. **CSRF** — отклонение запроса без/с чужим токеном; привязка токена к сессии (фикс 1.2).
3. **IDOR** — обычный пользователь пытается передать `company_id` чужой компании в add/edit/delete →
должен работать только со своей.
4. **Изоляция в `queries`** — пользователь A не видит/не меняет записи компании B.
5. **Session fixation** — id сессии меняется после логина (фикс 1.3).
6. **Open redirect**`returnTo=//evil.com` не приводит к внешнему редиректу (фикс 1.4).
7. **Лимиты/гонки** — параллельные `createEntry` не превышают лимит (интеграционный, с БД).
8. **OIDC verify** — отклонение токена с чужим `iss`/`aud`, выбор ключа по `kid` (фикс 1.5).
Сейчас нет интеграционных тестов с БД и HTTP-слоем — основной пробел.
---
## 6. Готовность к продакшену — чек-лист блокеров
- [ ] ❌ Закрыть `/export` аутентификацией + фильтрацией по компании.
- [ ] ❌ Починить CSRF `getSessionIdentifier` (`req.session.id`).
- [ ]`session.regenerate()` при логине (fixation).
- [ ] ❌ Валидация `returnTo` (open redirect).
- [ ] ❌ OIDC: `issuer`/`audience`/`kid` + рефреш JWKS.
- [ ] ❌ Внешний session store (Redis/PG) вместо MemoryStore.
- [ ] ⚠️ TLS к БД с проверкой CA (`verify-full`).
- [ ] ⚠️ Fail-fast при дефолтных секретах в проде.
- [ ] ⚠️ Запретить `DEV_MODE`/`/dev-login` в проде.
- [ ] ⚠️ Включить CSP; подтвердить экранирование EJS (`<%= %>`).
- [ ] ⚠️ Добавить интеграционные тесты (export/CSRF/IDOR/fixation/OIDC).
---
## 7. Итоговая оценка по блокам
| Блок | Оценка |
|------|--------|
| SQL injection / параметризация | ✅ |
| Изоляция компаний (IDOR в роутах) | ✅ (но без тестов) |
| `/export` доступ | ❌ |
| CSRF-конфигурация | ❌ |
| Session-управление (fixation, store) | ❌ |
| OIDC / token verification | ❌ |
| Open redirect | ❌ |
| TLS к БД / секреты | ⚠️ |
| CSP / XSS (не подтверждено по views) | ⚠️ |
| Бизнес-логика / транзакции / гонки | ✅ |
| CIDR-валидация и агрегация | ✅ |
| Архитектура / DI | ✅ |
| Покрытие тестами | ⚠️ |
**Вывод:** ядро (валидация, агрегация, транзакции, изоляция в запросах) сделано грамотно.
Блокируют прод в первую очередь четыре вещи: публичный `/export`, сломанная привязка CSRF,
session fixation + MemoryStore и неполная проверка OIDC-токенов. После их устранения и добавления
интеграционных тестов проект можно выводить в эксплуатацию.