docs: перенос документации из IPWhiteList + CONTEXT.md (резюме для нового чата)

This commit is contained in:
2026-05-30 19:10:34 +03:00
parent c85a438ba2
commit d31b8952b4
18 changed files with 34600 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
# research/
Материалы обратного инжиниринга платформы Nubes — анализ трафика, HAR, токены, схемы взаимодействия.
В отличие от `docs/` (проектная документация), здесь — результаты исследования внешних систем, которые мы не контролируем.
+134
View File
@@ -0,0 +1,134 @@
# Сводка код-ревью IP WhiteList (все 5 файлов)
> Дата: 2026-05-30
## 🔴 Критические (блокеры прода)
### 1. JWT без проверки подписи — server.js
```js
const payload = JSON.parse(Buffer.from(auth.replace('Bearer ', '').split('.')[1], 'base64').toString());
```
Декодирует payload без верификации подписи. Любой подделывает токен → любая компания. Нужна `jose.jwtVerify(token, JWKS, { issuer: 'auth-api' })`.
### 2. Пустой req.user не возвращает 401 — server.js
```js
} catch { req.user = {}; }
next();
```
Невалидный токен → `req.user = {}``clientId = undefined` → анонимы делят NULL-компанию. Нужно `if (!req.user.clientId) return res.status(401).send(...)`.
### 3. Race conditions (TOCTOU) — queries.js
**Три гонки из-за отсутствия транзакций:**
- **Обход лимита:** два параллельных запроса читают `cnt=14`, оба вставляют → 16 записей
- **Дубли/пересечения:** проверка `existing` и `INSERT` не в транзакции
- **Дубли компаний:** `getOrCreateCompany` — два первых запроса новой компании оба не находят, оба INSERT
**Фикс:** `BEGIN``SELECT ... FOR UPDATE` строки компании → проверки → INSERT/UPDATE → `logAudit(..., client)``COMMIT`.
### 4. audit вне транзакции — queries.js
`logAudit` использует глобальный `pool`, не клиент транзакции. При сбое: запись есть, аудита нет (или наоборот). Передавать клиент транзакции в `logAudit`.
### 5. /export без авторизации — server.js
`getExportCIDRs()` без аргументов отдаёт CIDR всех компаний без проверки `req.user`. Публичная утечка whitelist всех клиентов.
### 6. DEV_MODE не привязан к NODE_ENV — server.js
`DEV_MODE=true` в проде → все становятся `WZ01325` без auth. Добавить `&& process.env.NODE_ENV !== 'production'`.
### 7. CSRF — server.js + index.ejs
POST-формы `/add`, `/delete/:id` без CSRF-токенов. Нужен `csurf` + `<input name="_csrf">` в формах.
### 8. Обход запрещённых диапазонов через суперсеть — validators.js
Блокировка использует `isSubnetOf(normalized, blocked)` — можно обойти `/22`, содержащей запрещённый `/24` (например `192.0.2.0/22` содержит TEST-NET-1). Заменить на `overlaps(normalized, blocked)`.
---
## 🟠 Схема БД (structure)
### 9. Нет UNIQUE на активный CIDR компании — schema.sql
Дубли держатся только на коде. Добавить:
```sql
CREATE UNIQUE INDEX uq_entries_active_cidr
ON whitelist_entries(company_id, value_cidr) WHERE deleted_at IS NULL;
```
### 10. value_cidr как VARCHAR — нет проверок в БД — schema.sql
БД не валидирует формат и не ловит пересечения. Варианты:
| Уровень | Что |
|---|---|
| Минимум | `CHECK (value_cidr ~ '^(\d{1,3}\.){3}\d{1,3}/\d{1,2}$')` |
| Production | Тип `CIDR` + exclusion constraint (btree_gist) для пересечений |
### 11. FK без явного ON DELETE — schema.sql
`REFERENCES companies(id)` без указания поведения. Явно задать `ON DELETE RESTRICT`.
---
## 🟡 Средние (production-hardening)
| # | Где | Что |
|---|---|---|
| 12 | server.js | Нет **helmet** — X-Frame-Options, CSP, HSTS отсутствуют |
| 13 | server.js | Нет **rate-limit** на `/add`, `/delete`, `/export` |
| 14 | server.js | `express.urlencoded` без `limit` — DoS большими телами |
| 15 | server.js | Нет глобального error-handler middleware |
| 16 | server.js | `/healthz` под auth — сломает k8s-пробу. Вынести выше |
| 17 | server.js | `?error=` в редиректе не читается в `res.render('/', ...)` — параметр молча теряется |
| 18 | server.js | Сырые `e.message` БД наружу — info leak. Маппить на дружелюбные сообщения |
| 19 | queries.js | `custom_limit = 0` игнорируется: `company.custom_limit \|\| defaultLimit``0 \|\| 15 = 15` |
| 20 | queries.js | `getOrCreateCompany` не атомарен (хотя UNIQUE на client_id спасает). Upsert: `INSERT ... ON CONFLICT` |
| 21 | queries.js | `deleteEntry` WHERE только по `id` — добавить `AND company_id = $3` для глубины защиты |
| 22 | schema.sql | `audit_log.company_id` без FK на companies (допустимо, но задокументировать) |
| 23 | schema.sql | `audit_log.action` — свободный VARCHAR. Добавить `CHECK (action IN ('CREATE','UPDATE','DELETE'))` |
| 24 | schema.sql | Индекс аудита только на company_id. Добавить `(company_id, created_at DESC)` |
| 25 | schema.sql | `updated_at` не обновляется автоматически. Добавить триггер |
| 26 | schema.sql | `SERIAL``GENERATED ALWAYS AS IDENTITY` (PG 10+) |
| 27 | schema.sql | `custom_limit` без CHECK ≥ 0 |
| 28 | validators.js | `parseInt('24abc') = 24` — глотает мусор. Проверять `^\d{1,2}$` |
| 29 | validators.js | Множественные слэши не отсекаются: `10.0.0.0/24/8` → средняя часть игнорируется |
| 30 | index.ejs | Нет client-валидации формата (ТЗ требует). Добавить `pattern` + `maxlength="18"` |
| 31 | index.ejs | `disabled` по лимиту обходится через DevTools — не баг, т.к. сервер проверяет |
| 32 | index.ejs | Инлайн-стили → `unsafe-inline` в CSP. Вынести в `.css` для строгой политики |
---
## 🟢 Безопасно (проверено)
- **SQL-инъекций нет** — все запросы параметризованы ($1, $2...)
- **XSS в EJS нет** — всё через `<%= %>`, `<%- %>` не используется
- **Изоляция компаний корректна** — `updateEntry`/`deleteEntry` проверяют `company_id`, `listEntries` фильтрует по компании
- **Сохранённого XSS через БД нет** — все поля экранируются
- **`overlaps()` формула корректна** — проверено 12 тестами, старая и новая формулы математически эквивалентны
- **Изоляция через схему БД** — записи привязаны к `company_id`, обход только через код (не схему)
- **Partial-индекс `idx_entries_active`** — правильный приём, soft-deleted не раздувают индекс
---
## Приоритет исправлений
| Порядок | Что | Блокирует |
|---|---|---|
| 1 | JWT — проверка подписи | Продакшен |
| 2 | Транзакции в createEntry/updateEntry/deleteEntry | Целостность данных |
| 3 | UNIQUE на активный CIDR в БД | Защита от гонок |
| 4 | 401 при пустом req.user | Auth |
| 5 | DEV_MODE → NODE_ENV | Безопасность прода |
| 6 | /export — авторизация | Утечка данных |
| 7 | CSRF-токены | Безопасность |
| 8 | `overlaps` вместо `isSubnetOf` в блокировке | Валидация |
| 9 | helmet + rate-limit | Production-hardening |
| 10 | Остальное (см. таблицу 🟡) | Качество |
+31257
View File
File diff suppressed because one or more lines are too long
+97
View File
@@ -0,0 +1,97 @@
# Код-ревью index.ejs — XSS, CSRF, clickjacking, client-валидация
> Дата: 2026-05-30
## 🟢 XSS — экранирование корректно
Весь динамический вывод идёт через `<%= %>`, который EJS экранирует (`&<>"'`). `<%- %>` не используется нигде. Векторы проверены:
- `<%= message %>`, `<%= error %>` — экранируются. Даже если в `error` попадёт сырая ошибка БД с `<script>`, она будет обезврежена.
- `<%= e.value_cidr %>`, `<%= e.comment %>`, `<%= e.created_by %>` (данные из БД) — экранируются.
- `<%= user.clientId %>`, `<%= user.email %>` — экранируются.
Сохранённого XSS через комментарий/email нет. Это сильная сторона шаблона.
⚠️ Единственный нюанс: `action="/delete/<%= e.id %>"``e.id` идёт в атрибут URL. Так как это integer из БД (SERIAL), инъекция невозможна. Но если тип когда-нибудь станет строковым — атрибутный контекст потребует особой осторожности. Сейчас безопасно.
## 🔴 CSRF — формы без токена (критично)
```html
<form method="POST" action="/add">
<form method="POST" action="/delete/<%= e.id %>" ...>
```
Ни одна форма не содержит CSRF-токена. Обе меняют состояние. Сторонний сайт может авто-сабмитить POST на `/add`/`/delete/:id`. Зеркалит находку из ревью server.js. Фикс — пробросить токен из middleware (`csurf`) и в каждой форме:
```html
<form method="POST" action="/add">
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
...
</form>
<form method="POST" action="/delete/<%= e.id %>" style="display:inline" onsubmit="return confirm('Удалить запись?')">
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<button class="btn btn-danger">Удалить</button>
</form>
```
(Требует прокидывания `csrfToken` в `res.render` во всех роутах server.js.)
## 🔴 Clickjacking — нет защиты фрейминга
Шаблон с кнопками «Удалить» можно встроить в `<iframe>` на фишинговом сайте и подложить под клик (UI redress). В самом EJS защиты нет — нужны заголовки на стороне server.js (`helmet``X-Frame-Options: DENY` / CSP `frame-ancestors 'none'`). Дублирует находку из ревью server.js. На уровне шаблона можно добавить CSP через meta (слабее заголовка, но лучше чем ничего):
```html
<meta http-equiv="Content-Security-Policy" content="frame-ancestors 'none'; default-src 'self'; style-src 'self' 'unsafe-inline'">
```
⚠️ `style-src 'unsafe-inline'` потребуется из-за инлайн-`<style>` и inline-атрибутов `style="..."` в header/кнопках — это ослабляет CSP. По-хорошему вынести стили в отдельный `.css` файл и убрать `unsafe-inline`.
## 🟡 disabled-поля обходятся через DevTools (это и есть главная дыра валидации)
```html
<input name="value" ... <%= used >= limit ? 'disabled' : '' %>>
<button ... <%= used >= limit ? 'disabled' : '' %>>
```
`disabled` — только UX. Атакующий через DevTools снимает атрибут и шлёт POST `/add` сверх лимита. ЭТО НЕ УЯЗВИМОСТЬ ШАБЛОНА, пока сервер проверяет лимит — а он проверяет (`createEntry`). Вывод: клиентский `disabled` не является защитой и не должен ею считаться; настоящая защита — серверная проверка лимита (она есть, но уязвима к гонке — см. ревью queries.js). Шаблон тут корректен ровно при условии серверной проверки.
## 🟡 Нет client-side валидации (несоответствие ТЗ)
ТЗ требует клиентскую валидацию формата IPv4/CIDR. Сейчас только `required` и серверная проверка. Пользователь узнаёт об ошибке только после round-trip. Добавить `pattern` для базовой проверки + JS для маски /22/32:
```html
<input name="value"
pattern="^(\d{1,3}\.){3}\d{1,3}(/\d{1,2})?$"
title="IPv4 или CIDR, например 203.0.113.0/24"
placeholder="Например: 203.0.113.10 или 203.0.113.0/24"
required <%= used >= limit ? 'disabled' : '' %>>
```
`pattern` — только формат; диапазон маски (/22–/32) и host-биты всё равно валидирует сервер (`validators.js`). Это UX-улучшение, не замена серверной проверки.
## 🟡 maxlength только на comment, не на value
`comment` имеет `maxlength="255"` (совпадает со схемой VARCHAR(255) — хорошо). У `value` нет `maxlength` — стоит добавить `maxlength="18"` под `VARCHAR(18)`, чтобы не слать заведомо длинное и для согласованности.
## 🟢 Утечка чужих данных — нет
В шаблоне выводятся только `user.clientId`/`user.email` (свои) и `entries` (своей компании, отфильтрованы по company_id в `listEntries`). Данных других компаний нет. Изоляция на уровне шаблона соблюдена (зависит от корректной фильтрации в queries.js — там она есть).
## 🟡 onsubmit confirm — не защита, но ок
`onsubmit="return confirm(...)"` легко обходится, но это UX-подтверждение, не security-контроль. Приемлемо.
## 🟡 favicon/иконка — внешних ресурсов нет
Все ресурсы локальные (`/favicon.png`, инлайн SVG, инлайн CSS). Нет внешних CDN → меньше поверхность для supply-chain. Хорошо. Обратная сторона — инлайн-стили мешают строгой CSP (см. выше).
---
**Итог:**
1. 🟢 XSS нет — всё через `<%= %>`, `<%- %>` не используется. Главная сильная сторона.
2. 🔴 CSRF-токенов в формах нет — добавить `_csrf` в `/add` и `/delete` (+ middleware в server.js).
3. 🔴 Clickjacking — защита только заголовками (helmet в server.js); опционально CSP-meta.
4. 🟡 `disabled` по лимиту обходится через DevTools — не баг шаблона при условии серверной проверки (она есть).
5. 🟡 Нет client-валидации формата (ТЗ требует) — добавить `pattern` + `maxlength` на `value`.
6. 🟡 Инлайн-стили вынудят `unsafe-inline` в CSP — вынести в отдельный .css для строгой политики.
Шаблон по XSS написан правильно; основные пробелы — CSRF и clickjacking (закрываются в server.js) и отсутствие клиентской валидации из ТЗ.
+133
View File
@@ -0,0 +1,133 @@
# Код-ревью queries.js — гонки, транзакции, безопасность
> Дата: 2026-05-30
## 🔴 Race condition 1 — обход лимита (TOCTOU, критично)
`createEntry`: между `SELECT COUNT(*)` (проверка лимита) и `INSERT` нет транзакции и блокировки. Два параллельных запроса от одной компании оба прочитают `cnt = 14`, оба пройдут проверку `cnt >= 15`, оба вставят запись → 16 записей при лимите 15. То же самое позволяет вставить две пересекающиеся/дублирующие записи одновременно (проверка `existing` тоже вне транзакции).
Фикс — обернуть всю операцию в транзакцию с блокировкой строки компании (`SELECT ... FOR UPDATE` сериализует параллельные вставки в рамках одной компании):
```js
async function createEntry(companyId, rawValue, comment, userEmail) {
const { cidr, wasNormalized } = validate(rawValue);
const client = await pool.connect();
try {
await client.query('BEGIN');
// блокируем строку компании — параллельные createEntry этой компании встают в очередь
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();
}
}
```
## 🔴 Race condition 2 — то же в updateEntry
`updateEntry` имеет идентичную проблему: проверка пересечений (`existing`) и `UPDATE` не в транзакции. Параллельное обновление двух записей в пересекающиеся CIDR пройдёт обе проверки. Обернуть так же: `BEGIN``SELECT ... FOR UPDATE` строки компании → проверки → `UPDATE``logAudit(...,client)``COMMIT`/`ROLLBACK`.
## 🔴 Race condition 3 — getOrCreateCompany (дубли компаний)
`getOrCreateCompany`: между `SELECT` и `INSERT` нет защиты. Два первых запроса новой компании оба не найдут строку и оба сделают `INSERT`. Спасает только `UNIQUE` на `client_id` в схеме (второй упадёт), но ошибка вылетит наружу некрасиво. Фикс — атомарный upsert:
```js
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];
}
```
## 🟡 audit_log пишется вне транзакции
`logAudit` использует глобальный `pool`, а не клиента транзакции. Если INSERT записи прошёл, а logAudit упал — запись есть, аудита нет (или наоборот при будущих изменениях). Аудит обязателен по ТЗ. Передавать клиента транзакции:
```js
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]
);
}
```
## 🟡 deleteEntry — UPDATE без company_id в WHERE
```js
await pool.query(
'UPDATE whitelist_entries SET deleted_by = $1, deleted_at = NOW() WHERE id = $2',
[userEmail, entryId]
);
```
`old` уже проверен по `company_id`, поэтому изоляция сейчас не нарушается. Но WHERE по одному `id` хрупкий — при рефакторинге легко потерять привязку. Дублировать company_id в WHERE для глубины защиты:
```js
await pool.query(
'UPDATE whitelist_entries SET deleted_by = $1, deleted_at = NOW() WHERE id = $2 AND company_id = $3',
[userEmail, entryId, companyId]
);
```
Также deleteEntry не в транзакции с logAudit — обернуть аналогично create/update.
## 🟡 getLimit — custom_limit = 0 игнорируется
```js
return company.custom_limit || defaultLimit;
```
Если админ задал `custom_limit = 0` (запретить компании добавлять), `0 || 15` вернёт 15. ТЗ разрешает снижать лимит. Фикс:
```js
return company.custom_limit != null ? company.custom_limit : defaultLimit;
```
## 🟢 SQL-инъекций нет
Все запросы параметризованы ($1, $2...). Конкатенации с пользовательским вводом нет. `listEntries`/`getAudit` строят SQL из булевых флагов, не из ввода — безопасно.
## 🟢 Изоляция по company_id
`updateEntry` и `deleteEntry` проверяют `company_id` при выборке `old` — пользователь компании А не затронет записи компании Б. Корректно (но см. замечание по deleteEntry WHERE).
---
**Итог:** SQL-инъекций и утечек между компаниями нет. Главная проблема — отсутствие транзакций: 3 эксплуатируемые гонки (обход лимита, дубли/пересечения, дубли компаний) + риск рассинхрона аудита. Все чинятся обёрткой в транзакцию с `FOR UPDATE` и передачей клиента в logAudit. Плюс мелкий баг с `custom_limit = 0`.
+141
View File
@@ -0,0 +1,141 @@
# Код-ревью schema.sql — индексы, constraint'ы, FK, типы
> Дата: 2026-05-30
## 🔴 Нет уникального constraint на активный CIDR компании
Дубликаты предотвращаются только в коде (`createEntry`), а это уязвимо к гонке (см. ревью queries.js — TOCTOU). БД должна гарантировать уникальность активного адреса в рамках компании независимо от кода:
```sql
CREATE UNIQUE INDEX IF NOT EXISTS uq_entries_active_cidr
ON whitelist_entries(company_id, value_cidr) WHERE deleted_at IS NULL;
```
Это превращает существующий `idx_entries_active` в уникальный (можно заменить им) — параллельные INSERT одинакового CIDR упадут на втором, гонка закрывается на уровне БД. Пересечения (overlaps) так не закрыть — для них нужен `inet`/GiST (см. ниже) или транзакция.
## 🔴 value_cidr хранится как VARCHAR — нет валидации и пересечений на уровне БД
```sql
value_cidr VARCHAR(18) NOT NULL,
```
`VARCHAR(18)` хранит произвольную строку — БД не проверяет, что это валидный CIDR, и не умеет искать пересечения. Production-вариант — нативный тип `cidr`:
```sql
value_cidr CIDR NOT NULL,
```
Преимущества: БД отвергает мусор; операторы `&&` (overlaps), `<<=` (subnet); можно сделать exclusion constraint на пересечения внутри компании:
```sql
CREATE EXTENSION IF NOT EXISTS btree_gist;
ALTER TABLE whitelist_entries
ADD CONSTRAINT excl_entries_overlap
EXCLUDE USING gist (company_id WITH =, value_cidr inet_ops WITH &&)
WHERE (deleted_at IS NULL);
```
Это закрывает гонку пересечений (RC №2 из ревью queries.js) на уровне БД. Если тип менять не хотите — оставить VARCHAR, но тогда уникальность/пересечения держатся только на транзакциях в коде. Минимум — CHECK на формат:
```sql
ALTER TABLE whitelist_entries
ADD CONSTRAINT chk_cidr_format CHECK (value_cidr ~ '^(\d{1,3}\.){3}\d{1,3}/\d{1,2}$');
```
## 🔴 FK без ON DELETE / нет каскада
```sql
company_id INTEGER NOT NULL REFERENCES companies(id),
```
Поведение по умолчанию — `NO ACTION`: удалить компанию нельзя, пока есть записи. Для сервиса с soft-delete это, скорее, правильно (компании не удаляются физически). Но это надо сделать осознанно: явно указать `ON DELETE RESTRICT` (документирует намерение) либо `ON DELETE CASCADE`, если компании реально удаляются. Сейчас умолчание неявное.
## 🟡 audit_log.company_id без FK и без типизации действий
```sql
company_id INTEGER NOT NULL,
action VARCHAR(32) NOT NULL,
```
`company_id` в audit_log не ссылается на `companies` — допустимо (аудит должен переживать удаление компании), но тогда стоит это зафиксировать комментарием. `action` — свободный VARCHAR, можно записать что угодно. Ограничить:
```sql
ALTER TABLE audit_log
ADD CONSTRAINT chk_action CHECK (action IN ('CREATE','UPDATE','DELETE'));
```
`entry_id` тоже без FK — ок (запись может быть hard-удалена в будущем, аудит сохраняется).
## 🟡 Индекс аудита недостаточен для типичных запросов
```sql
CREATE INDEX idx_audit_company ON audit_log(company_id);
```
Аудит почти всегда смотрят «по компании, свежие сверху». Нужен составной с временем:
```sql
CREATE INDEX IF NOT EXISTS idx_audit_company_time
ON audit_log(company_id, created_at DESC);
```
## 🟡 Нет автообновления updated_at
`updated_at` в companies имеет DEFAULT NOW(), но при UPDATE не меняется автоматически — код должен сам выставлять. Для надёжности — триггер:
```sql
CREATE OR REPLACE FUNCTION set_updated_at() RETURNS trigger AS $$
BEGIN NEW.updated_at = NOW(); RETURN NEW; END $$ LANGUAGE plpgsql;
CREATE TRIGGER trg_companies_updated
BEFORE UPDATE ON companies
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
```
## 🟡 SERIAL вместо IDENTITY
`SERIAL` — легаси-приём. Для нового кода предпочтительнее:
```sql
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
```
Не критично, но это современный стандарт PG 10+ (чище права на sequence, нельзя случайно вставить id вручную).
## 🟡 custom_limit без CHECK на неотрицательность
```sql
custom_limit INTEGER DEFAULT NULL,
```
Можно записать отрицательный лимит. Добавить:
```sql
ALTER TABLE companies
ADD CONSTRAINT chk_custom_limit CHECK (custom_limit IS NULL OR custom_limit >= 0);
```
(Связано с багом `custom_limit = 0` из ревью queries.js — на уровне БД 0 разрешён, в коде игнорируется.)
## 🟡 comment/created_by — длины
`created_by VARCHAR(255)` под email — ок. `comment VARCHAR(255)` — приемлемо, но если ТЗ не ограничивает комментарий — рассмотреть TEXT. Не критично.
## 🟢 Изоляция через БД
Структурно обойти изоляцию нельзя: записи привязаны к `company_id`, утечка возможна только через код (запрос без фильтра company_id — см. `/export` в ревью server.js), не через схему.
## 🟢 Партиal-индекс idx_entries_active
Правильный приём — индекс только по активным записям, soft-deleted не раздувают индекс. Хорошо.
---
**Итог:**
1. 🔴 Добавить UNIQUE на активный (company_id, value_cidr) — закрывает гонку дублей на уровне БД.
2. 🔴 Рассмотреть тип `CIDR` + exclusion constraint (btree_gist) — закрывает гонку пересечений в БД; иначе минимум CHECK на формат.
3. 🔴 Явно задать ON DELETE для FK company_id.
4. 🟡 CHECK на action, на custom_limit ≥ 0; составной индекс аудита (company_id, created_at DESC); FK-политику аудита задокументировать.
5. 🟡 Триггер updated_at; перейти на IDENTITY вместо SERIAL.
Главное: текущая схема перекладывает уникальность и проверку пересечений целиком на код, который к ним уязвим в гонках. Перенос этих гарантий в БД (UNIQUE + exclusion/CHECK) — основной production-апгрейд.
+97
View File
@@ -0,0 +1,97 @@
# Код-ревью server.js — auth, CSRF, XSS, заголовки
> Дата: 2026-05-30
## 🔴 Auth bypass 1 — отсутствие проверки подписи JWT (критично)
```js
const payload = JSON.parse(Buffer.from(auth.replace('Bearer ', '').split('.')[1], 'base64').toString());
```
Это не аутентификация — это просто декодирование base64 payload без проверки подписи. Любой может прислать самодельный токен `Bearer xxx.<base64 любого JSON>.yyy` и стать любой компанией. `ClientID`, `company_id` полностью подконтрольны атакующему → полный обход изоляции компаний, доступ к чужим whitelist, экспорт. Это та самая дыра, ради которой нужен JWKS/проверка подписи (см. отдельный prompt-opus-auth). До внедрения проверки подписи продакшен поднимать нельзя.
Минимум: `jose.jwtVerify(token, JWKS, { issuer: 'auth-api' })` с кэшированием ключей, и брать payload только из верифицированного результата. Также `split('.')[1]` упадёт на токене без точек → попадёт в `catch``req.user = {}`.
## 🔴 Auth bypass 2 — пустой req.user не блокирует доступ
```js
} catch { req.user = {}; }
next();
```
При невалидном токене `req.user = {}` и запрос идёт дальше. В `/` тогда `clientId = undefined``getOrCreateCompany(undefined, undefined)` создаст/найдёт компанию с `client_id = NULL`. Все анонимы делят одну «нулевую» компанию, видят и редактируют её whitelist. Нет ни одного `return res.status(401)`. Фикс — после catch и для не-DEV пути: если нет `req.user.clientId``return res.status(401).send('Unauthorized')`.
## 🔴 DEV_MODE — риск включения в проде
```js
const DEV = process.env.DEV_MODE === 'true';
...
if (DEV) { req.user = { ... clientId: 'WZ01325' ... }; return next(); }
```
Если `DEV_MODE=true` случайно попадёт в прод-конфиг — полный обход аутентификации, все становятся `WZ01325`. Нет защиты «DEV только не в production». Добавить страховку: `const DEV = process.env.DEV_MODE === 'true' && process.env.NODE_ENV !== 'production';` и логировать предупреждение при старте, если DEV активен.
## 🔴 CSRF — все POST-формы без токенов (критично)
`/add`, `/delete/:id` — обычные form-POST, меняют состояние, без CSRF-токена. В проде аутентификация по cookie/сессии (а не по заголовку Authorization вручную) ⇒ сторонний сайт может отправить `<form action="https://white.../delete/123" method=POST>` и удалить чужие записи. Если же токен реально приходит только в заголовке Authorization (не в cookie), CSRF слабее — но форма в браузере не может сама проставить Authorization, значит модель аутентификации в браузере вообще не работает с текущим кодом. Это надо прояснить (см. questions.md). В любом случае при cookie-сессии нужен CSRF-токен (`csurf` или double-submit) на все POST.
## 🟡 XSS через ?error= в редиректе
```js
res.redirect('/?error=' + encodeURIComponent(e.message));
```
Сам редирект экранирует. Уязвимость — в шаблоне: если `index.ejs` выводит `error` через `<%- %>` (не экранируя) — рефлексивный XSS. По ревью ejs вывод идёт через `<%= %>`, так что сейчас безопасно. Но `e.message` может содержать сырой текст ошибки БД — нежелательно показывать пользователю (info leak). Маппить на дружелюбные сообщения, не отдавать `e.message` БД наружу.
Кроме того `/?error=` читается из query, но в роуте `/` параметр `req.query.error` вообще не прокидывается в render (`error: null`) — то есть редирект с `?error=` ничего не покажет. Несоответствие: либо читать `req.query.error`, либо убрать. Если читать — обязательно только экранированный вывод.
## 🟡 Обработка ошибок — каскад в /add
```js
} catch (e) {
const company = await q.getOrCreateCompany(clientId, companyName).catch(() => null);
const entries = company ? await q.listEntries(company.id).catch(() => []) : [];
...
```
В catch-ветке снова дёргается БД (3 запроса). Если БД легла — все упадут в `.catch(() => ...)` и пользователь увидит исходную ошибку с пустым списком — приемлемо, но шумно. Главное: нет глобального error-handler middleware (`app.use((err, req, res, next) => ...)`) — необработанный промис в любом роуте уронит ответ висящим. Добавить финальный error middleware + `process.on('unhandledRejection')`.
## 🟡 Нет helmet / security-заголовков
Отсутствуют `X-Frame-Options`/CSP (clickjacking), `X-Content-Type-Options: nosniff`, `Referrer-Policy`, HSTS. Добавить `helmet()` сразу после создания app. Особенно `frame-ancestors`/`X-Frame-Options: DENY` — UI с формами удаления уязвим к clickjacking.
## 🟡 Нет rate-limit
`/add`, `/delete`, `/export` без ограничения частоты. Можно засыпать INSERT-ами/экспортом. Добавить `express-rate-limit` на мутирующие роуты и на `/export`.
## 🟡 /export — нет авторизации и изоляции (важно по ТЗ)
```js
app.get('/export', async (req, res) => {
const cidrs = await q.getExportCIDRs();
```
`getExportCIDRs()` без аргументов — отдаёт CIDR ВСЕХ компаний всем подряд, без проверки `req.user`, без фильтра по компании. Это утечка whitelist всех клиентов. По ТЗ экспорт должен быть либо служебным (ограничен по IP/токену), либо в рамках компании. Сейчас — публичный дамп всех адресов. Требует решения из questions.md (IP-ограничение/служебный токен), но в текущем виде — критичная утечка.
## 🟡 /healthz выше auth — ок, но раскрывает «OK» только
`/healthz` объявлен после middleware (значит проходит через auth). При DEV ок; в проде healthz будет требовать токен → проба готовности k8s упадёт. Вынести `/healthz` ВЫШЕ auth middleware.
## 🟡 Тело запроса без лимита размера
`express.urlencoded({ extended: true })` без `limit`. DoS большими телами. Поставить `limit: '32kb'`.
## 🟢 SSRF/path traversal
`express.static` на фиксированную папку, шаблоны фиксированы — обхода нет.
---
**Итог (по приоритету):**
1. 🔴 JWT без проверки подписи → полный обход auth и изоляции — блокер прода.
2. 🔴 Пустой `req.user = {}` не возвращает 401 → анонимы делят NULL-компанию.
3. 🔴 `/export` без авторизации отдаёт CIDR всех компаний — утечка.
4. 🔴 DEV_MODE без привязки к NODE_ENV — риск обхода auth в проде.
5. 🔴 Нет CSRF на POST-формах.
6. 🟡 Нет helmet, rate-limit, лимита тела, глобального error-handler.
7. 🟡 `/healthz` под auth (сломает k8s-пробу), `?error=` не читается в `/`, сырые ошибки БД наружу.
+67
View File
@@ -0,0 +1,67 @@
# Ответ Claude Opus 4 — код-ревью validators.js
> Дата: 2026-05-30
## 🔴 Баг 1 — запрещённые диапазоны обходятся бо́льшей подсетью (критично)
Проверка блокировки использует `isSubnetOf(normalized, blocked)` — то есть «запись внутри запрещённого диапазона». Но не проверяется обратное направление: **запись, которая САМА содержит запрещённый диапазон**.
Поскольку ТЗ разрешает маски вплоть до `/22`, а многие запрещённые блоки — это `/24`, их можно обойти суперсетью:
```
Вход: 192.0.2.0/22
→ нормализуется в 192.0.0.0/22 (диапазон 192.0.0.0 192.0.3.255)
```
Этот `/22` **содержит** TEST-NET-1 (`192.0.2.0/24`) и IANA special (`192.0.0.0/24`), но `isSubnetOf` вернёт `false` (родитель не внутри ребёнка) → запись пройдёт валидацию. То же с `198.18.0.0/15` (Benchmarking) и др.
**Фикс** — использовать пересечение вместо вложенности:
```js
for (const blocked of BLOCKED_RANGES) {
if (overlaps(normalized, blocked)) {
throw new Error(`Диапазон ${normalized} пересекается с запрещённым (${blocked})`);
}
}
```
`overlaps` ловит оба направления (и когда запись внутри блока, и когда блок внутри записи). `isSubnetOf` после этого фикса в блокировке не нужен.
---
## 🟡 Замечание 2 — parseInt глотает мусор в маске
```js
const mask = parseInt(maskStr, 10);
```
`10.0.0.0/24abc``parseInt('24abc')` = `24`, запись принимается как `/24`. Строгая проверка:
```js
if (!/^\d{1,2}$/.test(maskStr)) throw new Error('Некорректная маска');
const mask = parseInt(maskStr, 10);
```
---
## 🟡 Замечание 3 — множественные слэши не отсекаются
`10.0.0.0/24/8`.split('/') → `['10.0.0.0','24','8']`, третий элемент молча игнорируется. Добавить проверку числа частей:
```js
const parts = cidr.split('/');
if (parts.length !== 2) throw new Error('Некорректный формат CIDR');
const [addr, maskStr] = parts;
```
---
## 🟢 Не баги (проверено)
- **`overlaps`** — формула `a.start <= b.end && b.start <= a.start || b.start <= a.end && a.start <= b.start` хоть и обфусцирована, **математически эквивалентна** стандартной `a.start <= b.end && b.start <= a.end`. Работает корректно. Можно упростить для читаемости, но это не дефект.
- **Битовая арифметика** с `<<8` уходит в знаковый 32-бит, но финальный `>>> 0` всё исправляет. Корректно.
- **Маска `/0`**, `1 << (32-mask)` — безопасно, т.к. `mask` ограничен 2232.
---
**Итог:** один настоящий эксплуатируемый баг (#1 — обход TEST-NET/special через `/22`), два мелких по строгости парсинга. Главное — поправить блокировку на `overlaps`.
+49
View File
@@ -0,0 +1,49 @@
# Промпт для 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 <token>` к своему бэкенду
**Нам неизвестно:**
- 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
- Только практические варианты, которые можно закодить сейчас
- ВЕСЬ ОТВЕТ ОДНИМ БЛОКОМ — без свёрток, без интерактивных элементов
+245
View File
@@ -0,0 +1,245 @@
# Промпт для 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
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>Белые списки IP — Nubes</title>
<link rel="icon" href="/favicon.png" type="image/png">
<style>
:root {
--bg: #f5f5f5;
--card: #ffffff;
--text: #1a1a1a;
--muted: #6b7280;
--border: #d1d5db;
--grey-light: #f3f4f6;
--blue: #2563eb;
--blue-h: #1d4ed8;
--red: #dc2626;
--red-h: #b91c1c;
--green: #16a34a;
--amber: #d97706;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg);
color: var(--text);
font-size: 14px;
line-height: 1.5;
}
.page { max-width: 1100px; margin: 1.5rem auto; padding: 0 1rem; }
.card {
background: var(--card);
border: 1px solid var(--border);
border-radius: 12px;
box-shadow: 0 1px 2px rgba(0,0,0,.04);
margin-bottom: 1rem;
overflow: hidden;
}
.card-header {
background: var(--grey-light);
padding: .75rem 1rem;
font-weight: 600;
font-size: 1rem;
border-bottom: 1px solid var(--border);
}
.card-body { padding: 1rem; }
.alert { padding: .75rem 1rem; border-radius: 8px; margin-bottom: 1rem; font-size: .9rem; }
.alert-ok { background: #dcfce7; color: #166534; border: 1px solid #bbf7d0; }
.alert-err { background: #fecaca; color: #991b1b; border: 1px solid #fca5a5; }
.alert-warn{ background: #fef3c7; color: #92400e; border: 1px solid #fde68a; }
.stats { display: flex; gap: 2rem; }
.stat { text-align: center; }
.stat .num { font-size: 1.6rem; font-weight: 700; }
.stat .lbl { font-size: .8rem; color: var(--muted); }
.stat.full .num { color: var(--red); }
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr auto;
gap: .75rem 1rem;
align-items: end;
}
.field { display: flex; flex-direction: column; gap: .25rem; }
.field label {
font-size: .8rem;
font-weight: 500;
color: var(--muted);
text-transform: uppercase;
letter-spacing: .5px;
}
.field input, .field textarea {
padding: .5rem .75rem;
border: 1px solid var(--border);
border-radius: 6px;
font-size: .9rem;
outline: none;
transition: border .15s;
background: #fff;
}
.field input:focus { border-color: var(--blue); box-shadow: 0 0 0 3px rgba(37,99,235,.1); }
.btn {
display: inline-flex;
align-items: center;
gap: .35rem;
padding: .5rem 1rem;
border: 1px solid var(--border);
border-radius: 6px;
font-size: .85rem;
font-weight: 500;
background: #fff;
cursor: pointer;
transition: background .15s;
white-space: nowrap;
}
.btn:hover { background: var(--grey-light); }
.btn-primary { background: var(--blue); color: #fff; border-color: var(--blue); }
.btn-primary:hover { background: var(--blue-h); }
.btn-primary:disabled { background: #93c5fd; cursor: not-allowed; border-color: #93c5fd; }
.btn-danger { color: var(--red); border-color: var(--red); }
.btn-danger:hover { background: #fecaca; }
.table-wrap {
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
}
table { width: 100%; border-collapse: collapse; font-size: .85rem; }
th {
text-align: left;
padding: .5rem .75rem;
background: var(--grey-light);
color: var(--muted);
font-weight: 500;
font-size: .8rem;
text-transform: uppercase;
letter-spacing: .5px;
border-bottom: 1px solid var(--border);
border-right: 1px solid var(--border);
}
th:last-child { border-right: none; }
td {
padding: .5rem .75rem;
border-bottom: 1px solid var(--border);
border-right: 1px solid var(--border);
}
td:last-child { border-right: none; }
tr:last-child td { border-bottom: none; }
tr:hover td { background: #f8fafc; }
code { background: #f1f5f9; padding: .15rem .4rem; border-radius: 3px; font-size: .9em; }
.empty { text-align: center; color: var(--muted); padding: 2rem; }
.text-center { text-align: center; }
.mt-3 { margin-top: 1rem; }
</style>
</head>
<body>
<header style="background:#fff;border-bottom:1px solid var(--border);padding:0 1.5rem;height:48px;display:flex;align-items:center;gap:.75rem;font-size:.9rem;color:var(--muted);">
<svg width="130" height="28" viewBox="330 228 311 69" style="display:block;">...</svg>
<span>|</span>
<span style="font-weight:500;color:var(--text);">Белые списки IP</span>
</header>
<div class="page">
<% if (message) { %><div class="alert <%= wasNormalized ? 'alert-warn' : 'alert-ok' %>"><%= message %></div><% } %>
<% if (error) { %><div class="alert alert-err"><%= error %></div><% } %>
<div class="card">
<div class="card-body">
<div class="stats">
<div class="stat <%= used >= limit ? 'full' : '' %>">
<div class="num"><%= used %> / <%= limit %></div>
<div class="lbl">записей</div>
</div>
<div class="stat">
<div class="num"><%= user.clientId %></div>
<div class="lbl">компания</div>
</div>
<div class="stat">
<div class="num"><%= user.email %></div>
<div class="lbl">пользователь</div>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-header">Добавить адрес</div>
<div class="card-body">
<form method="POST" action="/add">
<div class="form-grid">
<div class="field">
<label>IPv4 адрес или подсеть CIDR</label>
<input name="value" placeholder="Например: 203.0.113.10 или 203.0.113.0/24" required <%= used >= limit ? 'disabled' : '' %>>
</div>
<div class="field">
<label>Комментарий</label>
<input name="comment" placeholder="Необязательно" maxlength="255">
</div>
<button class="btn btn-primary" type="submit" <%= used >= limit ? 'disabled' : '' %> style="align-self:end">
<%= used >= limit ? 'Лимит исчерпан' : 'Добавить' %>
</button>
</div>
</form>
</div>
</div>
<div class="card">
<div class="card-header">Доверенные адреса</div>
<% if (entries.length === 0) { %>
<div class="empty">Нет добавленных адресов</div>
<% } else { %>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Адрес / Подсеть</th>
<th>Комментарий</th>
<th>Добавил</th>
<th>Дата</th>
<th class="text-center">Действие</th>
</tr>
</thead>
<tbody>
<% entries.forEach(e => { %>
<tr>
<td><code><%= e.value_cidr %></code></td>
<td><%= e.comment || '—' %></td>
<td><%= e.created_by %></td>
<td><%= new Date(e.created_at).toLocaleDateString('ru', {day:'numeric',month:'short',year:'numeric',hour:'2-digit',minute:'2-digit'}) %></td>
<td class="text-center">
<form method="POST" action="/delete/<%= e.id %>" style="display:inline" onsubmit="return confirm('Удалить запись?')">
<button class="btn btn-danger">Удалить</button>
</form>
</td>
</tr>
<% }) %>
</tbody>
</table>
</div>
<% } %>
</div>
</div>
</body>
</html>
```
File diff suppressed because it is too large Load Diff
+168
View File
@@ -0,0 +1,168 @@
# Промпт для 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,
};
```
+59
View File
@@ -0,0 +1,59 @@
# Промпт для 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);
```
+124
View File
@@ -0,0 +1,124 @@
# Промпт для 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}`));
```
+107
View File
@@ -0,0 +1,107 @@
# Промпт для 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 };
```