docs: перенос всей документации в ipwhitelist-app
This commit is contained in:
@@ -1,204 +0,0 @@
|
||||
# Состояние проекта на 2026-05-30 14:43 (ветка `sonnet`)
|
||||
|
||||
## Репозитории
|
||||
|
||||
| Репо | URL | Ветка | Локальный путь |
|
||||
|---|---|---|---|
|
||||
| Код приложения | `https://gitea.services.ngcloud.ru/Nail/ipwhitelist-app.git` | **sonnet** | `/home/naeel/ipwhitelist-app` |
|
||||
| Документация | `https://gitea.services.ngcloud.ru/Nail/IPWhiteList.git` | main | `/home/naeel/IPWhiteList` |
|
||||
|
||||
## Стек
|
||||
|
||||
Node.js + Express + EJS + PostgreSQL + pg pool + jsonwebtoken
|
||||
|
||||
## Деплой
|
||||
|
||||
- URL: `https://white.nodejsk8s.dev.nubes.ru`
|
||||
- DEV_MODE=true (мок-аутентификация)
|
||||
- ⚠️ Нужен передеплой Nubes для ветки sonnet
|
||||
|
||||
## БД
|
||||
|
||||
- `write.bde8229b-1381-4330-b24b-727ad73fcb44.dev.nubes.ru`
|
||||
- user: `super`, db: `ipwhitelist`
|
||||
- Миграция от 2026-05-30 применена (CHECK, UNIQUE, индексы)
|
||||
|
||||
---
|
||||
|
||||
## Что сделано в ветке `sonnet` (4 коммита поверх master)
|
||||
|
||||
### 1. `src/validators.js` — CIDR агрегация
|
||||
- `aggregateCIDRs(cidrs)` — суммаризация: merge пересекающихся + смежных диапазонов → минимальный набор CIDR
|
||||
- Вспомогательные: `numToIP(n)`, `rangeToCIDRs(start, end)`
|
||||
- Экспортируется и используется в `/export`
|
||||
|
||||
### 2. `src/auth.js` — admin-роль
|
||||
- `ADMIN_CLIENT_ID` = `process.env.ADMIN_CLIENT_ID || 'WZ01112'`
|
||||
- `req.user.isAdmin` — определяется по `clientId === ADMIN_CLIENT_ID`
|
||||
- `DEV_ADMIN=true` в `.env` → admin-права в dev-режиме
|
||||
- `requireAdmin` middleware — 403 для не-admin
|
||||
|
||||
### 3. `src/queries.js` — новые функции
|
||||
- `getCompanyById(id)` — компания по числовому PK
|
||||
- `getAllCompanies()` — все компании + `active_count` (LEFT JOIN)
|
||||
- `setLimit(companyId, newLimit)` — установить/сбросить (null) индивидуальный лимит
|
||||
- `getAudit()` — обновлён: JOIN с companies (поля `company_name`, `client_id`)
|
||||
|
||||
### 4. `server.js` — новые роуты
|
||||
- `/export` — публичный (до auth.middleware), возвращает агрегированный список всех компаний
|
||||
- `GET /` — admin: видит все компании с переключателем `?company=X`
|
||||
- `POST /edit/:id` — редактирование записи (admin + user)
|
||||
- `GET /audit` — журнал аудита (только admin)
|
||||
- `GET /admin` — управление лимитами (только admin)
|
||||
- `POST /admin/limit/:companyId` — изменить/сбросить лимит компании
|
||||
- `backUrl()` — хелпер для редиректа обратно с учётом контекста admin/user
|
||||
|
||||
### 5. `views/index.ejs`
|
||||
- Admin-панель выбора компании (dropdown + быстрые ссылки на Аудит/Лимиты)
|
||||
- Кнопки Аудит/Лимиты/Выйти в header для admin
|
||||
- Кнопка "Изменить" в таблице (открывает edit modal)
|
||||
- Edit modal — overlay с формой, закрывается по Escape/backdrop
|
||||
- Скрытый `company_id` в формах add/delete для корректной admin-ветки
|
||||
|
||||
### 6. `views/audit.ejs` — **новый**
|
||||
- Таблица журнала аудита с фильтром по компании
|
||||
- Цветные badges (CREATE/UPDATE/DELETE)
|
||||
- old_value → new_value стрелочкой
|
||||
|
||||
### 7. `views/admin.ejs` — **новый**
|
||||
- Таблица всех компаний: client_id, active_count, лимит
|
||||
- Прогресс-бар использования (зелёный/янтарный/красный)
|
||||
- Форма изменения лимита с подтверждением; кнопка ↺ сброс на дефолт
|
||||
|
||||
---
|
||||
|
||||
## Не сделано (production-hardening, не баги)
|
||||
|
||||
- helmet (X-Frame-Options, CSP, HSTS)
|
||||
- rate-limit на POST /add, /delete, /export
|
||||
- CSRF-токены в формах
|
||||
- JWT-верификация через внешний JWKS (нужен URL от девопсов)
|
||||
- Multi-company (нужен формат claims от платформы — массив clientId?)
|
||||
|
||||
## Мёртвый код (не критично)
|
||||
|
||||
- `isSubnetOf()` в validators.js — определена, не используется, не экспортируется
|
||||
|
||||
## Для прода
|
||||
|
||||
Выставить в `.env`:
|
||||
```
|
||||
NODE_ENV=production
|
||||
JWKS_URL=<auth-api JWKS URL>
|
||||
ADMIN_CLIENT_ID=<clientId администратора>
|
||||
DEFAULT_LIMIT=15
|
||||
```
|
||||
|
||||
|
||||
## Репозитории
|
||||
|
||||
| Репо | URL | Ветка | Локальный путь |
|
||||
|---|---|---|---|
|
||||
| Код приложения | `https://gitea.services.ngcloud.ru/Nail/ipwhitelist-app.git` | master | `/home/naeel/ipwhitelist-app` |
|
||||
| Документация | `https://gitea.services.ngcloud.ru/Nail/IPWhiteList.git` | main | `/home/naeel/IPWhiteList` |
|
||||
|
||||
## Стек
|
||||
|
||||
Node.js + Express + EJS + PostgreSQL + pg pool + jsonwebtoken
|
||||
|
||||
## Деплой
|
||||
|
||||
- URL: `https://white.nodejsk8s.dev.nubes.ru`
|
||||
- DEV_MODE=true (мок-аутентификация)
|
||||
- ⚠️ Код запушен, но Nubes не передеплоил — крутится старая версия
|
||||
|
||||
## БД
|
||||
|
||||
- `write.bde8229b-1381-4330-b24b-727ad73fcb44.dev.nubes.ru`
|
||||
- user: `super`, db: `ipwhitelist`
|
||||
- Миграция от 2026-05-30 применена (CHECK, UNIQUE, индексы)
|
||||
|
||||
---
|
||||
|
||||
## Что сделано (запушено)
|
||||
|
||||
### 1. `src/validators.js` — исправлены 3 бага
|
||||
- Запрещённые диапазоны: `isSubnetOf` → `overlaps` (обход через суперсеть `/22`)
|
||||
- Маска: `parseInt('24abc')` глотал мусор → строгая проверка `/^\d{1,2}$/`
|
||||
- Множественные слэши: `10.0.0.0/24/8` теперь отклоняется
|
||||
|
||||
### 2. `src/queries.js` — транзакции + гонки
|
||||
- `createEntry`, `updateEntry`, `deleteEntry` — внутри транзакции с `SELECT ... FOR UPDATE`
|
||||
- `getOrCreateCompany` — атомарный `INSERT ... ON CONFLICT`
|
||||
- `getLimit` — `!= null` вместо `||` (custom_limit=0 не игнорируется)
|
||||
- `logAudit` — принимает клиента транзакции (пишется атомарно)
|
||||
- `getExportCIDRs` — фильтр по `companyId`
|
||||
- `deleteEntry` — `company_id` в WHERE
|
||||
|
||||
### 3. `src/auth.js` — **новый.** Мок JWT-аутентификация
|
||||
- Генерирует RSA-ключи при старте
|
||||
- JWKS endpoint: `/.well-known/jwks.json`
|
||||
- `verifyJWT(token)` — RS256, issuer: `mock-auth-api`
|
||||
- `issueJWT(claims)` — выпускает токен с claims как в HAR (`ClientID`, `company_id`, `company_name`, `email`)
|
||||
- Middleware: извлекает JWT из cookie (`jwt`) или `Authorization: Bearer`
|
||||
- DEV_MODE: при `DEV_MODE=true && NODE_ENV!=production` — обход auth
|
||||
- **Для прода:** выставить `JWKS_URL=https://auth-api.../jwks` → switches to external verification
|
||||
|
||||
### 4. `views/login.ejs` — **новый.** Мок-страница входа
|
||||
- Выбор из 3 пользователей (admin WZ01112, тест WZ01325, компания 2 WZ02001)
|
||||
- В проде заменяется на редирект в Keycloak
|
||||
|
||||
### 5. `server.js`
|
||||
- `cookie-parser` для чтения JWT из cookie
|
||||
- `/healthz` — выше auth (k8s probe)
|
||||
- `/login` GET/POST — мок-логин
|
||||
- `/logout` — чистит cookie
|
||||
- `/export` — только для своей компании (с авторизацией)
|
||||
- `req.query.error` читается
|
||||
- `urlencoded({ limit: '32kb' })`
|
||||
|
||||
### 6. `sql/schema.sql`
|
||||
- UNIQUE INDEX на активный `(company_id, value_cidr)` WHERE deleted_at IS NULL
|
||||
- CHECK на `value_cidr` формат
|
||||
- CHECK на `audit_log.action IN ('CREATE','UPDATE','DELETE')`
|
||||
- CHECK на `custom_limit IS NULL OR >= 0`
|
||||
- FK: `ON DELETE RESTRICT`
|
||||
- Составной индекс `(company_id, created_at DESC)` на audit_log
|
||||
- Индекс `(company_id, created_at DESC)` на whitelist_entries
|
||||
|
||||
### 7. `views/index.ejs`
|
||||
- `pattern` + `maxlength="18"` + `title` на инпуте value
|
||||
|
||||
---
|
||||
|
||||
## Ревью (`/home/naeel/IPWhiteList/research/`)
|
||||
|
||||
| Файл | Что |
|
||||
|---|---|
|
||||
| `REVIEW-SUMMARY.md` | Сводка всех находок (11 критических, 21 средний) |
|
||||
| `opus-review-validators.md` | 1 критичный + 2 средних |
|
||||
| `opus-review-queries.md` | 3 гонки + audit + getLimit |
|
||||
| `opus-review-server.md` | JWT без подписи, 401, CSRF, /export |
|
||||
| `opus-review-schema.md` | UNIQUE, CIDR, FK, индексы |
|
||||
| `opus-review-ejs.md` | CSRF, clickjacking, client-валидация |
|
||||
| `auth-flow.md` | Анализ HAR: claims, цепочка auth-api |
|
||||
|
||||
---
|
||||
|
||||
## Не сделано (production-hardening, не баги)
|
||||
|
||||
- helmet (X-Frame-Options, CSP, HSTS)
|
||||
- rate-limit на POST /add, /delete, /export
|
||||
- CSRF-токены в формах
|
||||
- JWT-верификация через внешний JWKS (нужен URL от девопсов)
|
||||
- Admin-признак в токене (нужен пример токена админа)
|
||||
- Multi-company (нужен формат claims от платформы)
|
||||
|
||||
## Для прода
|
||||
|
||||
Выставить в `.env`:
|
||||
```
|
||||
NODE_ENV=production
|
||||
JWKS_URL=<auth-api JWKS URL>
|
||||
```
|
||||
Всё остальное работает без изменений.
|
||||
Binary file not shown.
@@ -1,156 +0,0 @@
|
||||
Техническое задание
|
||||
Микросервис управления доверенными адресами клиентов
|
||||
Self-service портал для указания клиентами доверенных IPv4-адресов и подсетей,
|
||||
исключаемых из блокировки на стороне облачного провайдера во время DDoS-атак
|
||||
|
||||
───────────────────────────────────────────────────────────────
|
||||
КРАТКОЕ ОПИСАНИЕ (пояснение к реализации)
|
||||
───────────────────────────────────────────────────────────────
|
||||
Сервис даёт клиентам облачного провайдера личный кабинет, где они сами указывают
|
||||
свои доверенные IPv4-адреса и подсети. Эти адреса провайдер не блокирует во время
|
||||
DDoS-атак — так легитимный трафик клиента не попадает под ложные срабатывания
|
||||
фильтрации.
|
||||
|
||||
Что реализуется:
|
||||
|
||||
Личный кабинет клиента. Клиент входит через привычную авторизацию (Keycloak),
|
||||
видит свой список доверенных адресов и управляет им сам: добавляет, редактирует,
|
||||
удаляет записи с комментариями. Если пользователь работает с несколькими
|
||||
компаниями — переключается между ними.
|
||||
|
||||
Проверка вводимых данных. Форма принимает только корректные IPv4-адреса и подсети,
|
||||
отклоняет «серые» и служебные диапазоны, не допускает дубликатов и пересечений
|
||||
внутри одной компании.
|
||||
|
||||
Ограничение по количеству. На компанию по умолчанию 15 записей. Лимит
|
||||
настраивается глобально, а для отдельной компании администратор может поднять или
|
||||
опустить его индивидуально.
|
||||
|
||||
Режим администратора (сетевые инженеры провайдера). Единое окно, где видны записи
|
||||
всех компаний, с фильтрами и доступом к истории изменений. Администратор управляет
|
||||
лимитами и при необходимости любыми записями.
|
||||
|
||||
История изменений (аудит). Каждое создание, изменение и удаление фиксируется: кто,
|
||||
когда, что именно изменил. Удаление — логическое, данные физически сохраняются.
|
||||
|
||||
Выдача для систем фильтрации. Отдельный адрес, по которому системы защиты
|
||||
автоматически забирают итоговый сводный список всех доверенных адресов (одним
|
||||
txt-файлом, по строке на запись). Адреса при этом схлопываются в компактный общий
|
||||
перечень.
|
||||
───────────────────────────────────────────────────────────────
|
||||
1. Назначение и цели
|
||||
1.1. Назначение
|
||||
Микросервис предоставляет клиентам облачного провайдера web-интерфейс для самостоятельного управления списком доверенных IPv4-адресов и подсетей. Записи из этого списка исключаются из автоматической блокировки сетевого взаимодействия системами фильтрации и митигации провайдера, что снижает количество ложноположительных срабатываний для легитимного трафика клиента.
|
||||
1.2. Цели
|
||||
Дать клиентам возможность самостоятельно поддерживать актуальный список доверенных IPv4-адресов, которые будут исключаться из фильтрации во время DDoS-атак.
|
||||
Предоставить сетевым инженерам единую точку просмотра и управления списками доверенных клиентских белых IPv4-адресов.
|
||||
Обеспечить машиночитаемую выдачу агрегированного (суммаризированного) списка для систем фильтрации трафика.
|
||||
2. Объем работ
|
||||
Web-страница / закладка в личном кабинете для управления whitelist-записями.
|
||||
Авторизация через существующий экземпляр Keycloak (OIDC).
|
||||
Валидация формы на стороне клиента и сервера.
|
||||
Внешний endpoint выдачи агрегированного списка. Выдача txt-файлом с переносом строки. Одна строка – один объект.
|
||||
Хранение записей, журнал аудита.
|
||||
Административное управление лимитами по компаниям.
|
||||
3. Роли и права доступа
|
||||
Роли определяются на основании claims в OIDC-токене Keycloak. Соответствие claim → роль настраивается на этапе развёртывания.
|
||||
Роль
|
||||
Идентификация
|
||||
Видимость записей
|
||||
Права на изменение
|
||||
Клиент (client)
|
||||
clientId
|
||||
Только записи компаний, к которым принадлежит пользователь.
|
||||
Создание, редактирование и удаление записей своих компаний (в пределах лимита).
|
||||
Администратор (admin)
|
||||
clientId = WZ01112 (Нубес) и отдельный чек-бокс
|
||||
Записи всех компаний.
|
||||
Создание, редактирование, удаление всех записей. Изменение лимита для отдельных компаний.
|
||||
3.1. Принадлежность к компании
|
||||
Принадлежность пользователя к компании определяется из claim токена. Поддерживается сценарий, когда пользователь принадлежит нескольким компаниям: в этом случае в интерфейсе предусматривается переключатель активной компании, а все операции выполняются в контексте выбранной компании.
|
||||
Ожидаемые claims (имена согласуются с командой Keycloak):
|
||||
clientID — идентификатор компании
|
||||
email — идентификация пользователя для аудита
|
||||
4. Функциональные требования
|
||||
4.1. Просмотр списка записей
|
||||
Клиент видит таблицу записей активной компании; Администратор – записи всех компаний с фильтром по компании.
|
||||
Для каждой записи отображаются: значение (адрес/подсеть), комментарий (если есть), автор(email), дата создания, дата последнего изменения.
|
||||
Soft-deleted записи по умолчанию скрыты; для администратора предусмотрен фильтр для их отображения.
|
||||
Отображается текущее использование лимита: «использовано X из N».
|
||||
4.2. Создание записи
|
||||
Форма содержит поля: значение (IPv4-адрес или подсеть CIDR) и необязательный комментарий (до 255 символов).
|
||||
Значение проходит валидацию (см. раздел 5) на клиенте и обязательно повторно на сервере.
|
||||
Перед сохранением проверяется: соблюдение лимита компании, отсутствие пересечений и дубликатов внутри компании, отсутствие принадлежности к запрещённым диапазонам.
|
||||
При успешном сохранении создаётся запись аудита.
|
||||
4.3. Редактирование записи
|
||||
Редактирование значения и комментария доступно компании в рамках своих прав.
|
||||
При изменении значения повторно выполняется полный набор проверок валидации и пересечений.
|
||||
Изменение фиксируется в журнале аудита с сохранением прежнего и нового значения.
|
||||
4.4. Удаление записи (soft delete)
|
||||
Удаление выполняется как логическое (soft delete): запись помечается удалённой (deleted_at, deleted_by), но физически сохраняется.
|
||||
Удалённая запись освобождает место в лимите компании и исключается из внешней агрегированной выдачи.
|
||||
Действие фиксируется в журнале аудита.
|
||||
4.5. Лимит записей на компанию
|
||||
Действует глобальный лимит по умолчанию: 15 активных записей на компанию.
|
||||
Значение глобального лимита по умолчанию задаётся конфигурацией сервиса и может быть изменено без пересборки.
|
||||
Для отдельной компании администратор может задать индивидуальный лимит, переопределяющий глобальный (как в большую, так и в меньшую сторону).
|
||||
При попытке превысить лимит создание блокируется с понятным сообщением; в подсчёт идут только активные записи.
|
||||
Снижение лимита ниже текущего числа записей не удаляет существующие записи, но блокирует создание новых до приведения в соответствие.
|
||||
4.6. Журнал аудита
|
||||
Все изменяющие операции фиксируются неизменяемыми записями аудита.
|
||||
Каждая запись аудита содержит: кто (пользователь), когда (timestamp), компания, тип действия, прежнее и новое состояние.
|
||||
Журнал доступен для просмотра только администратору.
|
||||
4.7. Внешняя выдача агрегированного списка
|
||||
Подсети суммаризируются (агрегируются в минимальный набор CIDR) по всем компаниям совместно. Пересечения между разными компаниями допустимы.
|
||||
Предоставляется отдельный HTTP GET endpoint, отдающий полный суммаризированный список активных записей всех компаний файлом в формате txt.
|
||||
Авторизация: на старте endpoint может работать без авторизации (по сетевому ограничению / разрешенный список потребителей по ip).
|
||||
5. Требования к валидации
|
||||
Валидация выполняется на клиенте и обязательно дублируется на сервере. Серверная валидация является авторитетной.
|
||||
Правило
|
||||
Описание
|
||||
Формат IPv4
|
||||
Допускается одиночный адрес (например 203.0.113.10) или подсеть в нотации CIDR (например 203.0.113.0/24). Допускается использование масок /32 - /22. Маска /21 и больше не допускается.
|
||||
Только IPv4
|
||||
IPv6-значения или доменные имена отклоняются.
|
||||
Корректность подсети
|
||||
Введенный адрес с маской подсети должен нормализоваться к адресу подсети, все host-биты должны быть обнулены.
|
||||
Пользователь должен быть уведомлен, что ввел адрес из хостовой части, а не адрес подсети и произошла нормализация.
|
||||
Запрет серых адресов
|
||||
Адреса и подсети из частных диапазонов (Приложение А) запрещены к добавлению.
|
||||
Отсутствие дубликатов
|
||||
В пределах одной компании запрещены полностью совпадающие записи.
|
||||
Отсутствие пересечений
|
||||
В пределах одной компании запрещено добавление записи, пересекающейся с уже существующей (включая вложенность подсетей). Между разными компаниями пересечения допускаются.
|
||||
Длина комментария
|
||||
Не более 255 символов; поле необязательное.
|
||||
Приложение А – Список запрещенных к созданию подсетей.
|
||||
Назначение
|
||||
Префикс
|
||||
Private (RFC1918)
|
||||
10.0.0.0/8
|
||||
Private (RFC1918)
|
||||
172.16.0.0/12
|
||||
Private (RFC1918)
|
||||
192.168.0.0/16
|
||||
CGNAT (RFC6598)
|
||||
100.64.0.0/10
|
||||
Loopback
|
||||
127.0.0.0/8
|
||||
Link-local (APIPA)
|
||||
169.254.0.0/16
|
||||
IANA special block
|
||||
192.0.0.0/24
|
||||
TEST-NET-1 (docs)
|
||||
192.0.2.0/24
|
||||
TEST-NET-2 (docs)
|
||||
198.51.100.0/24
|
||||
TEST-NET-3 (docs)
|
||||
203.0.113.0/24
|
||||
Benchmarking
|
||||
198.18.0.0/15
|
||||
Multicast
|
||||
224.0.0.0/4
|
||||
Reserved (Class E)
|
||||
240.0.0.0/4
|
||||
Limited broadcast
|
||||
255.255.255.255/32
|
||||
@@ -1,112 +0,0 @@
|
||||
# Анализ проекта IP WhiteList — 30.05.2026
|
||||
|
||||
## Что сделано
|
||||
|
||||
| Компонент | Файл | Строк | Статус |
|
||||
|---|---|---|---|
|
||||
| Express-сервер, роуты | `server.js` | 101 | ✅ |
|
||||
| Валидатор IPv4/CIDR | `src/validators.js` | 99 | ✅ |
|
||||
| CRUD + audit_log | `src/queries.js` | 149 | ✅ |
|
||||
| Пул PG | `src/db.js` | 24 | ✅ |
|
||||
| UI (EJS, стиль Nubes) | `views/index.ejs` | 248 | ✅ |
|
||||
| Схема БД | `sql/schema.sql` | 39 | ✅ |
|
||||
| Favicon | `public/favicon.png` | — | ✅ |
|
||||
| Дизайн-система (документ) | `docs/nubes-design-system.md` | — | ✅ |
|
||||
| Тестирование | `tests/test-results.md` | 16/16 ✅ | ✅ |
|
||||
|
||||
### Валидатор — что проверяет:
|
||||
- RFC1918 (10/8, 172.16/12, 192.168/16) — запрет
|
||||
- TEST-NET (192.0.2/24, 198.51.100/24, 203.0.113/24) — запрет
|
||||
- Loopback, link-local — запрет
|
||||
- IPv6 — запрет
|
||||
- Маска: допускается только /22–/32
|
||||
- Нормализация host-битов (10.0.0.1/24 → 10.0.0.0/24)
|
||||
- Пересечения с уже существующими записями — запрет
|
||||
- Лимит записей на компанию (по умолчанию 15)
|
||||
|
||||
### Экспорт:
|
||||
- `GET /export` → plain text, один CIDR на строку, без заголовков
|
||||
|
||||
---
|
||||
|
||||
## Что не готово (критично для продакшена)
|
||||
|
||||
### 1. JWT-авторизация
|
||||
**Сейчас:** `DEV_MODE=true` — авторизация заглушена, `clientId` захардкожен.
|
||||
**Нужно:** Распаковка JWT из `Authorization: Bearer <token>` → извлечение `clientId` и `email`.
|
||||
Схема описана в `docs/auth-architecture.md`.
|
||||
**Риск:** без этого нельзя открывать сервис для реальных пользователей — любой запрос видит и меняет все данные.
|
||||
|
||||
### 2. Изоляция данных по компании
|
||||
**Сейчас:** Все роуты используют один захардкоженный `clientId`. БД правильная (таблица `companies` есть), но `WHERE company_id = $1` не работает по-настоящему без JWT.
|
||||
**Нужно:** После JWT — всё заработает автоматически, код менять не придётся.
|
||||
|
||||
### 3. CSRF-защита на POST-формах
|
||||
**Сейчас:** Формы без CSRF-токена.
|
||||
**Нужно:** `csurf` middleware или `SameSite=Strict` на session cookie.
|
||||
Или, если фронт перейдёт на fetch/JSON API — Authorization header автоматически решает проблему.
|
||||
|
||||
---
|
||||
|
||||
## Замечания и предложения
|
||||
|
||||
### Архитектура
|
||||
|
||||
- `server.js` содержит все роуты в одном файле (101 строка). Пока норм, но при добавлении админки/API станет неудобно. Рекомендую разбить на `routes/user.js`, `routes/admin.js`, `routes/api.js` перед тем как добавлять функциональность.
|
||||
|
||||
- `queries.js` возвращает сырые строки, не объекты с явным типом. Если сервис будет расти — стоит обернуть в Result-паттерн `{ ok, data, error }`.
|
||||
|
||||
### UI
|
||||
|
||||
- Нет пагинации. При лимите 15 записей на компанию — не критично. Если лимит поднимут до 100+ — нужна.
|
||||
- Нет поиска/фильтрации в таблице.
|
||||
- Алерты исчезают только при перезагрузке страницы (flash-сообщения). Если добавить JS — можно auto-dismiss через 5 секунд.
|
||||
|
||||
### Экспорт
|
||||
|
||||
- Сейчас `GET /export` отдаёт все CIDR без суммаризации. Если у компании 10 записей типа `1.2.3.0/28` и `1.2.3.16/28` — они будут двумя строками. Суммаризация в `/28` + `/28` → `/27` сократила бы файл и упростила настройку оборудования. Это отдельная задача, потребует библиотеку CIDR-merge.
|
||||
|
||||
### Тесты
|
||||
|
||||
- Тесты ручные (curl в md-файле). Для CI/CD нужен автоматический прогон: `jest` или `supertest`. Команда для инициализации: `npm install --save-dev jest supertest`.
|
||||
|
||||
### БД
|
||||
|
||||
- `audit_log` пишется, но нигде не отображается пользователю. Для полноты — стоит добавить вкладку "История" или endpoint `GET /audit`.
|
||||
- Нет индекса на `whitelist_entries(company_id)` — при росте данных будет полный скан. Добавить в `schema.sql`:
|
||||
```sql
|
||||
CREATE INDEX idx_whitelist_company ON whitelist_entries(company_id);
|
||||
CREATE INDEX idx_audit_company ON audit_log(company_id);
|
||||
```
|
||||
|
||||
### Безопасность
|
||||
|
||||
- `.env` в `.gitignore` ✅ — правильно.
|
||||
- `secrets.txt` в корне репо — нужно убедиться что он тоже в `.gitignore`.
|
||||
- Параметризованные запросы в `queries.js` ✅ — SQL-инъекции закрыты.
|
||||
- `express-validator` не подключён — валидация только на уровне `validators.js`. Нормально, т.к. все входные данные проходят через него.
|
||||
|
||||
---
|
||||
|
||||
## Приоритеты следующих задач
|
||||
|
||||
1. **JWT** — без этого продакшен не открыть
|
||||
2. **CSRF** — параллельно с JWT
|
||||
3. **Индексы БД** — 2 строки в schema.sql, риск нулевой
|
||||
4. **Разбивка роутов** — перед добавлением админки
|
||||
5. **Автотесты** — перед CI/CD
|
||||
6. **Суммаризация CIDR** — nice to have
|
||||
7. **Пагинация + фильтр** — после поднятия лимита
|
||||
8. **Audit UI** — опционально
|
||||
|
||||
---
|
||||
|
||||
## Текущий стек
|
||||
|
||||
- Node.js + Express.js
|
||||
- PostgreSQL (pg pool)
|
||||
- EJS templates
|
||||
- Платформа: Nubes NodeJS instance "white"
|
||||
- URL: `https://white.nodejsk8s.dev.nubes.ru`
|
||||
- Repo (код): `gitea.services.ngcloud.ru/Nail/ipwhitelist-app.git`
|
||||
- Repo (docs): `gitea.services.ngcloud.ru/Nail/IPWhiteList.git`
|
||||
@@ -1,95 +0,0 @@
|
||||
# Архитектура авторизации облачного портала
|
||||
|
||||
> ✅ ПРОВЕРЕНО: API успешно вызван через curl 2026-05-29.
|
||||
|
||||
---
|
||||
|
||||
## Схема аутентификации
|
||||
|
||||
```
|
||||
Пользователь (браузер)
|
||||
│
|
||||
▼
|
||||
Keycloak (keycloak.nubes.ru, realm=cloud)
|
||||
│ Authorization Code Flow
|
||||
│ client_id=deck.ngcloud.ru
|
||||
│ scope=openid email
|
||||
▼
|
||||
auth-api.ngcloud.ru (СОБСТВЕННЫЙ сервис)
|
||||
│ Создаёт свой JWT (issuer="auth-api")
|
||||
│ Подпись: RS256 (RSA)
|
||||
▼
|
||||
deck.ngcloud.ru (портал)
|
||||
│ Микро-фронтенды (SPA): dashboard, contracts, services, ...
|
||||
│ JWT хранится в localStorage: authApiTokens.access_token
|
||||
▼
|
||||
IPWhiteList (наш сервис) ← будет встроен как микро-фронтенд в портал
|
||||
```
|
||||
|
||||
## Важное
|
||||
|
||||
- JWT подписывает **не Keycloak**, а **auth-api**
|
||||
- Issuer: `"auth-api"`, алгоритм: `RS256`
|
||||
- Для валидации нужен публичный ключ auth-api (JWKS или статический)
|
||||
|
||||
---
|
||||
|
||||
## Структура JWT (access_token)
|
||||
|
||||
_Из localStorage → authApiTokens → access_token_
|
||||
|
||||
| Поле | Тип | Значение (пример) | Назначение |
|
||||
|---|---|---|---|
|
||||
| `iss` | string | `"auth-api"` | Кто выпустил токен |
|
||||
| `sub` | string | `"0199e325-..."` | UUID пользователя |
|
||||
| `iat` | number | `1780073927` | Выпущен (Unix time) |
|
||||
| `exp` | number | `1780117127` | Истекает (~12 часов) |
|
||||
| `jti` | string | `"4d8d7240-..."` | Уникальный ID токена |
|
||||
| `ClientID` | string | `"WZ01325"` | ID **ТЕКУЩЕЙ** компании пользователя |
|
||||
| `company_id` | string (UUID) | `"3e64aac6-..."` | UUID компании |
|
||||
| `company_name` | string | `"Тест"` | Название компании |
|
||||
| `email` | string | `"tazet@narod.ru"` | Email (для аудита) |
|
||||
| `login` | string | `"tazet@narod.ru"` | Логин |
|
||||
| `firstname` | string | `"Наиль"` | Имя |
|
||||
| `lastname` | string | `"Тазетдинов"` | Фамилия |
|
||||
| `token_type` | string | `"access"` | Тип токена |
|
||||
|
||||
## Что НЕ в JWT (отдельный authData в localStorage)
|
||||
|
||||
_Из localStorage → authData → v_
|
||||
|
||||
| Поле | Значение | Где используется |
|
||||
|---|---|---|
|
||||
| `userInfo.isAdmin` | `false` (boolean) | ⚠️ Признак администратора |
|
||||
| `profiles[]` | Массив `{company_id, company_name, is_active_profile}` | Список всех компаний пользователя |
|
||||
| `roles[]` | Массив `{role_id, role_name}` | Роли пользователя |
|
||||
| `permissions.can_write` | `false` | Есть ли права на запись |
|
||||
|
||||
---
|
||||
|
||||
## Открытые вопросы (нужно уточнить с командой портала)
|
||||
|
||||
1. **Валидация JWT.** ✅ Выяснено: API gateway (`lk-api-gateway.ngcloud.ru`) сам валидирует JWT через auth-api. Нам достаточно передавать `Authorization: Bearer <JWT>`.
|
||||
|
||||
2. **isAdmin.** Флаг админа есть только в authData localStorage, но не в JWT. Как наш сервис узнает что пользователь — админ?
|
||||
- Нужно уточнить: можно ли добавить `isAdmin` в JWT или получать через API gateway
|
||||
|
||||
3. **Список компаний.** В JWT — только одна компания. В authData.profiles — массив. Для переключателя компаний — откуда брать список?
|
||||
|
||||
4. **Монтирование в API gateway.** Наш сервис будет за `lk-api-gateway.ngcloud.ru` как отдельный route (например `/api/v1/whitelist/...`). Нужно уточнить процедуру добавления нового route.
|
||||
|
||||
---
|
||||
|
||||
## Как вызывать API (проверено curl'ом)
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer <JWT>" \
|
||||
-H "Origin: https://deck.ngcloud.ru" \
|
||||
-H "Cookie: __ddg1_=...; __ddg8_=...; __ddg9_=...; __ddg10_=..." \
|
||||
"https://lk-api-gateway.ngcloud.ru/api/v1/..."
|
||||
```
|
||||
|
||||
- **API Gateway:** `lk-api-gateway.ngcloud.ru` (не deck-api.ngcloud.ru!)
|
||||
- **DDOS-Guard cookies ОБЯЗАТЕЛЬНЫ** (без них 403)
|
||||
- **JWT issuer:** `auth-api`
|
||||
- **Алгоритм:** RS256
|
||||
@@ -1,131 +0,0 @@
|
||||
# План разработки IP WhiteList — актуальный (30.05.2026)
|
||||
|
||||
> Основан на ТЗ (`WhiteIPlist.txt`) и реальном состоянии кода.
|
||||
> Все предыдущие планы (plan.md, plan-v2.md, plan-working.md, plan-gemini.md, plan-gpt54.md) устарели.
|
||||
|
||||
---
|
||||
|
||||
## Что уже работает (не трогать)
|
||||
|
||||
| Компонент | Файл | Состояние |
|
||||
|---|---|---|
|
||||
| Валидатор IPv4/CIDR | `src/validators.js` | ✅ Полный — все 14 диапазонов Приложения А |
|
||||
| CRUD функции | `src/queries.js` | ✅ `createEntry`, `updateEntry`, `deleteEntry`, `listEntries`, `getExportCIDRs`, `getAudit` |
|
||||
| Soft delete | `src/queries.js` + `sql/schema.sql` | ✅ `deleted_at`, `deleted_by` |
|
||||
| Схема БД | `sql/schema.sql` | ✅ Все таблицы и индексы |
|
||||
| Аудит-лог запись | `src/queries.js` | ✅ Пишется при CREATE/UPDATE/DELETE |
|
||||
| UI главная страница | `views/index.ejs` | ✅ Стиль Nubes |
|
||||
| Экспорт txt | `server.js GET /export` | ✅ Работает (без суммаризации) |
|
||||
| Лимит + custom_limit | `src/queries.js` | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## Что есть в queries.js, но не подключено к роутам
|
||||
|
||||
- `updateEntry` — написана, роута `POST /update/:id` нет, UI-кнопки нет
|
||||
- `getAudit` — написана, роута и страницы нет
|
||||
- `listEntries(companyId, includeDeleted=true)` — параметр есть, но нигде не вызывается с `true`
|
||||
|
||||
---
|
||||
|
||||
## Что отсутствует полностью
|
||||
|
||||
### A. Роуты (server.js)
|
||||
- `POST /update/:id` — редактирование записи
|
||||
- `GET /admin` — страница администратора
|
||||
- `POST /admin/limit/:companyId` — изменение лимита компании
|
||||
- `GET /admin/audit` — журнал аудита (только для admin)
|
||||
|
||||
### B. UI (views/)
|
||||
- Кнопка и inline-форма редактирования в таблице
|
||||
- Страница admin (`views/admin.ejs`):
|
||||
- Список всех компаний
|
||||
- Записи с фильтром по компании
|
||||
- Переключатель "показать удалённые"
|
||||
- Журнал аудита
|
||||
- Изменение лимита компании
|
||||
|
||||
### C. Авторизация
|
||||
- Текущий код: наивный base64 decode JWT — не проверяет подпись, не Keycloak
|
||||
- Нужно: `openid-client` (OIDC), интеграция с Keycloak, проверка `clientID` и `email` из claims
|
||||
- Admin-роль: `clientId === 'WZ01112'` — определяется из claim
|
||||
|
||||
### D. Суммаризация CIDR в экспорте
|
||||
- ТЗ требует агрегацию в минимальный набор CIDR
|
||||
- Нужна библиотека: `npm install cidr-tools` или `ip-range-check`
|
||||
- Или самописный merge (sorted ranges → contiguous merge)
|
||||
|
||||
### E. Клиентская валидация
|
||||
- ТЗ: валидация на клиенте обязательна, серверная — авторитетная
|
||||
- Сейчас: только серверная
|
||||
- Нужно: JS в index.ejs — проверить формат CIDR, маску /22–/32, не RFC1918
|
||||
|
||||
### F. Переключатель компаний
|
||||
- ТЗ: пользователь может быть в нескольких компаниях (несколько `clientId` в токене)
|
||||
- Нужно: UI-dropdown, переключение активного `clientId`, хранение выбора в сессии
|
||||
|
||||
---
|
||||
|
||||
## Обнаруженные баги
|
||||
|
||||
### overlaps() в validators.js — логическая ошибка
|
||||
|
||||
```js
|
||||
// Текущий код (неверно):
|
||||
return a.start <= b.end && b.start <= a.start ||
|
||||
b.start <= a.end && a.start <= b.start;
|
||||
|
||||
// Правильно:
|
||||
return a.start <= b.end && b.start <= a.end;
|
||||
```
|
||||
|
||||
Стандартное условие пересечения отрезков. Текущий код даёт false positive для некоторых смежных диапазонов.
|
||||
**Приоритет: высокий** — влияет на корректность проверки пересечений.
|
||||
|
||||
---
|
||||
|
||||
## Порядок реализации
|
||||
|
||||
### Этап 1 — Критические фиксы (не ломают работу)
|
||||
1. Исправить `overlaps()` в `validators.js`
|
||||
2. Добавить `POST /update/:id` в `server.js`
|
||||
3. Добавить кнопку редактирования + inline-форму в `views/index.ejs`
|
||||
|
||||
### Этап 2 — Полнота ТЗ
|
||||
4. Клиентская валидация (JS в index.ejs)
|
||||
5. Суммаризация CIDR в `GET /export` (cidr-tools или ручной merge)
|
||||
6. Определение admin-роли (`clientId === 'WZ01112'`)
|
||||
7. Страница `/admin` + роут `GET /admin`
|
||||
8. Роут `POST /admin/limit/:companyId`
|
||||
9. Журнал аудита для admin (`GET /admin/audit`)
|
||||
|
||||
### Этап 3 — Авторизация (блокирующая для продакшена)
|
||||
10. `npm install openid-client` — OIDC Keycloak
|
||||
11. Заменить auth middleware на реальную проверку токена
|
||||
12. Убрать `DEV_MODE` (или оставить только для локальной разработки)
|
||||
|
||||
### Этап 4 — Опционально
|
||||
13. Переключатель компаний (multi-company claim)
|
||||
14. Автотесты: `npm install --save-dev jest supertest`
|
||||
15. Пагинация (только если лимит вырастет > 50)
|
||||
16. Audit UI для клиента (не только admin)
|
||||
|
||||
---
|
||||
|
||||
## Зависимости
|
||||
|
||||
```
|
||||
openid-client — OIDC / Keycloak
|
||||
cidr-tools — суммаризация CIDR в экспорте
|
||||
```
|
||||
|
||||
Всё остальное — в рамках существующего стека (Express, EJS, pg).
|
||||
|
||||
---
|
||||
|
||||
## Что НЕ нужно делать
|
||||
|
||||
- Переписывать validators.js (только исправить overlaps)
|
||||
- Переписывать queries.js — всё уже написано
|
||||
- Менять схему БД — она полная
|
||||
- Пересобирать стек — Express + EJS достаточно для ТЗ
|
||||
@@ -1,55 +0,0 @@
|
||||
# Актуальный план разработки — IP WhiteList Microservice
|
||||
|
||||
> **Автор:** GitHub Copilot (Gemini 3.1 Pro Preview)
|
||||
> **Дата:** 2026-05-30
|
||||
> **Основание:** ТЗ (WhiteIPlist.txt) + Реальный код (Node.js/Express)
|
||||
|
||||
## 1. Анализ предыдущего плана и моё мнение
|
||||
|
||||
Мой предыдущий план (`plan-gemini.md`) оказался **полностью оторванным от реальности**. Я предполагал писать всё с нуля на Python/FastAPI+SQLModel. На деле же ядро полностью готово и написано на **Node.js, Express, EJS и чистом SQL (pg pool)**.
|
||||
Писать с нуля на питоне — это плодить техдолг и выкидывать рабочий код. Более того, серверная валидация, структура БД, CRUD и UI уже в целом соответствуют ТЗ, но сильно не хватает связующих звеньев.
|
||||
|
||||
Поэтому мой главный тезис: **хватит придумывать архитектуру, нужно закрывать дыры по функциональным требованиям ТЗ в текущем Node.js проекте.**
|
||||
|
||||
## 2. Разрыв между ТЗ (WhiteIPlist.txt) и кодом (Node.js)
|
||||
|
||||
Что готово и работает:
|
||||
- **База данных:** Полная структура (`companies`, `whitelist_entries`, `audit_log`), реализован soft delete (`deleted_at`).
|
||||
- **Слой данных (`queries.js`):** Работает базовый CRUD, сохраняются логи аудита, обрабатываются ограничения (глобальные и кастомные).
|
||||
- **Валидация (`validators.js`):** Реализованы проверки на IPv4, маски /22-/32, зашиты все запрещённые диапазоны (RFC1918, CGNAT, Loopback и т.д.).
|
||||
- **UI:** Причесанный EJS-шаблон добавления/просмотра/удаления.
|
||||
|
||||
Чего не хватает по ТЗ (Фокус дальнейшей разработки):
|
||||
1. **Авторизация (Keycloak OIDC):** Сейчас сделан временный парсинг JWT через Base64 без валидации ключей (DEV_MODE).
|
||||
2. **Multi-company и Роли (Админ/Клиент):** Не реализованы переключатель компаний и админские страницы (видимость всех записей, изменение лимитов, просмотр аудита).
|
||||
3. **Редактирование записей:** Метод `updateEntry` написан в БД-слое, но UI и роут отсутствуют. Формально ТЗ не закрыто.
|
||||
4. **Агрегация в Экспорте:** Маршрут `GET /export` просто выплёвывает адреса в столбик, тогда как ТЗ жёстко требует суммаризировать подсети в минимальный набор CIDR.
|
||||
5. **Клиентская валидация:** ТЗ явно требует валидировать формат и маски на фронтенде перед отправкой.
|
||||
|
||||
## 3. Детальный план по шагам (Node.js)
|
||||
|
||||
### Этап 1: Исправление багов в текущем MVP
|
||||
- [ ] **Баг с overlaps:** В `validators.js` функция `overlaps()` содержит логическую ошибку в `start`/`end` (может пропускать пересекающиеся подсети). Переписать условие.
|
||||
- [ ] **UI Редактирования:** Добавить роут `POST /update/:id` в `server.js` и добавить кнопку/форму "Изменить" в `index.ejs`, подключив существующую `updateEntry(...)`.
|
||||
|
||||
### Этап 2: Строгое соответствие ТЗ (Валидация и Экспорт)
|
||||
- [ ] **Суммаризация CIDR:** Так как в Node.js нет `ipaddress.collapse_addresses`, необходимо использовать библиотеку вроде `cidr-tools` (функция `merge()` отлично справится) для `GET /export`.
|
||||
- [ ] **Клиентская валидация:** Добавить минимальный JavaScript в `index.ejs` для проверки валидности вводимого IP-адреса и маски до ухода POST-запроса, чтобы экономить серверные ресурсы (Требование ТЗ).
|
||||
|
||||
### Этап 3: Подключение OIDC Keycloak
|
||||
- [ ] Установка библиотеки `openid-client` (или `passport-openidconnect`).
|
||||
- [ ] Настройка middleware: получение сертификатов из Keycloak, валидация подписи JWT-токена.
|
||||
- [ ] Извлечение claim `clientID` (и логика переключения между компаниями, если `clientID` является массивом). Определение роли (кто админ, `WZ01112`). Обязательный отказ от dev-заглушки в PROD.
|
||||
|
||||
### Этап 4: Админ-панель (пользователь WZ01112)
|
||||
- [ ] Роут `GET /admin` и шаблон `admin.ejs`. Таблица со всеми компаниями и их лимитами, фильтрацией.
|
||||
- [ ] Функционал установки `custom_limit` для компании.
|
||||
- [ ] Страница/вкладка `GET /admin/audit` для просмотра `audit_log`.
|
||||
- [ ] Переключатель отображения Soft-deleted записей.
|
||||
|
||||
## 4. Зависимости
|
||||
В `package.json` придется добавить лишь две новые production-зависимости, не усложняя проект:
|
||||
- `cidr-tools` (для агрегации при экспорте)
|
||||
- `openid-client` / `jsonwebtoken` (для безопасной работы с Keycloak)
|
||||
|
||||
Всё остальное будет реализовано в рамках существующего стека.
|
||||
@@ -1,552 +0,0 @@
|
||||
# Актуальный план реализации — IP WhiteList
|
||||
|
||||
> Автор: GitHub Copilot (GPT-5.4)
|
||||
> Дата: 2026-05-30
|
||||
> Основание: ТЗ из WhiteIPlist.txt + фактический код в ipwhitelist-app
|
||||
> Статус: заменяет предыдущую версию плана
|
||||
|
||||
## 1. Вывод по текущему состоянию
|
||||
|
||||
Предыдущий план был неполным, потому что строился не от ТЗ, а от видимого MVP. После сверки с WhiteIPlist.txt картина такая:
|
||||
|
||||
1. Основа сервиса уже написана лучше, чем казалось: схема БД, soft delete, аудит, custom_limit, updateEntry и полный список запрещённых диапазонов уже есть в коде.
|
||||
2. Основной разрыв находится не в модели данных, а между слоями: часть функций реализована в src/queries.js, но не выведена в server.js и views.
|
||||
3. Главные недостающие вещи для соответствия ТЗ: нормальная OIDC-авторизация, admin-сценарии, редактирование из UI, клиентская валидация, агрегирующий экспорт.
|
||||
4. Переписывать стек или БД не нужно. Нужно довести существующую реализацию до полноты требований.
|
||||
|
||||
## 2. Что требует ТЗ
|
||||
|
||||
Сервис по ТЗ обязан поддерживать:
|
||||
|
||||
1. Self-service страницу управления whitelist-записями.
|
||||
2. Авторизацию через Keycloak OIDC.
|
||||
3. Роли client и admin.
|
||||
4. Возможность работы пользователя с одной или несколькими компаниями.
|
||||
5. Создание, редактирование и soft delete записей.
|
||||
6. Серверную и клиентскую валидацию IPv4/CIDR.
|
||||
7. Глобальный лимит и индивидуальные лимиты по компаниям.
|
||||
8. Журнал аудита.
|
||||
9. Внешний endpoint выдачи агрегированного списка активных CIDR.
|
||||
10. Admin-функции: просмотр всех компаний, фильтрация, просмотр удалённых записей, изменение лимитов.
|
||||
|
||||
## 3. Что уже реализовано сейчас
|
||||
|
||||
### 3.1 База данных
|
||||
|
||||
В sql/schema.sql уже есть всё базовое, что нужно для ТЗ:
|
||||
|
||||
1. Таблица companies с client_id и custom_limit.
|
||||
2. Таблица whitelist_entries с updated_by, updated_at, deleted_by, deleted_at.
|
||||
3. Таблица audit_log.
|
||||
4. Индекс активных записей и индекс по audit_log(company_id).
|
||||
|
||||
Вывод: схему БД переписывать не нужно.
|
||||
|
||||
### 3.2 Серверная логика
|
||||
|
||||
В src/queries.js уже реализованы:
|
||||
|
||||
1. getOrCreateCompany
|
||||
2. getLimit
|
||||
3. listEntries с includeDeleted
|
||||
4. createEntry
|
||||
5. updateEntry
|
||||
6. deleteEntry как soft delete
|
||||
7. getExportCIDRs
|
||||
8. getAudit
|
||||
|
||||
Вывод: слой работы с БД уже покрывает значительную часть ТЗ.
|
||||
|
||||
### 3.3 Валидация
|
||||
|
||||
В src/validators.js уже есть:
|
||||
|
||||
1. Только IPv4.
|
||||
2. Маски только /22–/32.
|
||||
3. Нормализация host-битов.
|
||||
4. Запрет всех диапазонов из Приложения А ТЗ, включая CGNAT, Benchmarking, Multicast, Reserved и Limited broadcast.
|
||||
5. Проверка пересечений.
|
||||
|
||||
Вывод: серверная валидация по составу требований почти полная.
|
||||
|
||||
### 3.4 UI и маршруты
|
||||
|
||||
В server.js и views/index.ejs уже есть:
|
||||
|
||||
1. Главная страница со списком записей.
|
||||
2. Форма добавления.
|
||||
3. Soft delete через POST /delete/:id.
|
||||
4. Экспорт через GET /export.
|
||||
5. Вывод текущего лимита и числа использованных записей.
|
||||
6. UI, близкий к стилю Nubes.
|
||||
|
||||
Вывод: клиентский сценарий создания и удаления уже работает как MVP.
|
||||
|
||||
## 4. Что не соответствует ТЗ или не доведено до конца
|
||||
|
||||
### 4.1 Авторизация
|
||||
|
||||
Сейчас в server.js не OIDC, а упрощённый decode токена через base64 без проверки подписи. Это подходит только как временная заглушка, но не соответствует ТЗ.
|
||||
|
||||
Нужно:
|
||||
|
||||
1. Реальная проверка JWT через Keycloak/JWKS или openid-client.
|
||||
2. Нормальный маппинг claims в user-модель.
|
||||
3. Явное определение роли admin.
|
||||
4. Поддержка сценария с несколькими компаниями.
|
||||
|
||||
Это главный блокер продакшна.
|
||||
|
||||
### 4.2 Редактирование записи
|
||||
|
||||
updateEntry уже написана, но:
|
||||
|
||||
1. Нет роута POST /update/:id.
|
||||
2. Нет формы редактирования в index.ejs.
|
||||
3. Нет пользовательского сценария изменения записи.
|
||||
|
||||
То есть требование ТЗ формально не закрыто, хотя код на уровне queries уже есть.
|
||||
|
||||
### 4.3 Admin-функции
|
||||
|
||||
По ТЗ администратор должен:
|
||||
|
||||
1. Видеть записи всех компаний.
|
||||
2. Фильтровать по компании.
|
||||
3. Видеть soft-deleted записи.
|
||||
4. Смотреть аудит.
|
||||
5. Менять лимиты компаний.
|
||||
|
||||
Сейчас ничего из этого не выведено в server.js и views.
|
||||
|
||||
### 4.4 Экспорт
|
||||
|
||||
GET /export уже есть, но он отдаёт просто список value_cidr без агрегации. ТЗ требует суммаризацию в минимальный набор CIDR по всем активным записям.
|
||||
|
||||
Это функциональный разрыв, а не косметика.
|
||||
|
||||
### 4.5 Клиентская валидация
|
||||
|
||||
ТЗ требует валидацию на клиенте и сервере. Сейчас есть только серверная.
|
||||
|
||||
Нужно добавить в форму как минимум:
|
||||
|
||||
1. Проверку формата IPv4/CIDR.
|
||||
2. Проверку диапазона маски.
|
||||
3. Ограничение длины комментария.
|
||||
4. Сообщение о возможной нормализации.
|
||||
|
||||
### 4.6 Multi-company сценарий
|
||||
|
||||
ТЗ явно говорит, что пользователь может принадлежать нескольким компаниям. Сейчас всё построено вокруг одного clientId в req.user.
|
||||
|
||||
Нужно:
|
||||
|
||||
1. Понять реальный формат claims.
|
||||
2. Ввести activeCompany в контекст пользователя.
|
||||
3. Добавить переключатель активной компании в UI.
|
||||
|
||||
## 5. Что не надо перепридумывать
|
||||
|
||||
1. Не надо переписывать проект на другой язык или другой фреймворк.
|
||||
2. Не надо менять схему БД ради самой схемы.
|
||||
3. Не надо переписывать validators.js целиком.
|
||||
4. Не надо переписывать queries.js целиком.
|
||||
5. Не надо делать SPA.
|
||||
|
||||
Правильный путь: минимально нарастить уже существующую Node.js/Express/EJS реализацию.
|
||||
|
||||
## 6. Риски и спорные места
|
||||
|
||||
### 6.1 overlaps в validators.js
|
||||
|
||||
Условие в overlaps написано нестандартно и плохо читается. Прямого доказанного бага по одной только формуле сейчас нет, но это место требует отдельного теста на:
|
||||
|
||||
1. полное совпадение,
|
||||
2. вложенность,
|
||||
3. непересекающиеся диапазоны,
|
||||
4. соседние диапазоны,
|
||||
5. одиночный IP против подсети.
|
||||
|
||||
Решение: сначала добавить точечные тесты, и только потом менять формулу, если тест покажет дефект.
|
||||
|
||||
### 6.2 Admin-роль
|
||||
|
||||
ТЗ говорит: admin это clientId = WZ01112 и отдельный чек-бокс. Сейчас неясно, как этот чек-бокс попадает в токен. Без этого нельзя финально закрыть auth-модель.
|
||||
|
||||
### 6.3 Multi-company claims
|
||||
|
||||
ТЗ требует сценарий нескольких компаний, но в списке claims указан только clientID. Здесь нужна конкретика от команды Keycloak.
|
||||
|
||||
## 7. Новый план реализации
|
||||
|
||||
### Этап 1. Довести до полноты пользовательский сценарий
|
||||
|
||||
Цель: закрыть основной client-flow без смены архитектуры.
|
||||
|
||||
1. Добавить роут POST /update/:id в server.js.
|
||||
2. Добавить UI редактирования в views/index.ejs.
|
||||
3. Показать updated_at и updated_by, если запись менялась.
|
||||
4. Добавить клиентскую валидацию формы добавления и редактирования.
|
||||
5. Добавить точечные тесты на overlaps и нормализацию.
|
||||
|
||||
Результат этапа: client сможет не только добавлять и удалять, но и редактировать записи, как требует ТЗ.
|
||||
|
||||
### Этап 2. Закрыть admin-функциональность
|
||||
|
||||
Цель: реализовать недостающую управленческую часть ТЗ.
|
||||
|
||||
1. Ввести определение роли admin в auth-слое.
|
||||
2. Добавить GET /admin.
|
||||
3. Добавить фильтр по компании.
|
||||
4. Добавить показ удалённых записей.
|
||||
5. Добавить GET /admin/audit.
|
||||
6. Добавить POST /admin/limit/:companyId.
|
||||
7. Добавить отдельный admin view.
|
||||
|
||||
Результат этапа: появляется реальная административная панель, а не только клиентский экран.
|
||||
|
||||
### Этап 3. Привести авторизацию к ТЗ
|
||||
|
||||
Цель: убрать временную заглушку и сделать реальную интеграцию с Keycloak.
|
||||
|
||||
1. Заменить наивный decode токена на верификацию подписи.
|
||||
2. Добавить конфигурацию issuer, audience, jwks/oidc.
|
||||
3. Нормализовать claims в req.user.
|
||||
4. Поддержать admin-claim.
|
||||
5. Поддержать multi-company claims.
|
||||
6. Оставить DEV_MODE только для локальной разработки.
|
||||
|
||||
Результат этапа: сервис можно выводить из чисто dev-сценария.
|
||||
|
||||
### Этап 4. Довести экспорт до требований ТЗ
|
||||
|
||||
Цель: сделать экспорт пригодным для систем фильтрации.
|
||||
|
||||
1. Добавить суммаризацию активных записей в минимальный набор CIDR.
|
||||
2. Суммаризировать по всем компаниям совместно.
|
||||
3. Исключать soft-deleted записи.
|
||||
4. Оставить выдачу text/plain, одна строка на объект.
|
||||
5. Отдельно решить, где ограничивается доступ к export endpoint: ingress, app или оба уровня.
|
||||
|
||||
Результат этапа: экспорт соответствует ТЗ, а не является просто дампом таблицы.
|
||||
|
||||
### Этап 5. Завершение и проверка полноты
|
||||
|
||||
1. Сверить все пункты ТЗ с реализованным поведением.
|
||||
2. Обновить тестовый сценарий.
|
||||
3. Проверить UX ошибок и предупреждений.
|
||||
4. Проверить поведение при снижении custom_limit ниже текущего числа активных записей.
|
||||
5. Проверить сценарии admin/client отдельно.
|
||||
|
||||
## 8. Практический приоритет
|
||||
|
||||
Если делать не всё сразу, а по реальной важности, порядок такой:
|
||||
|
||||
1. Редактирование записи из UI.
|
||||
2. Клиентская валидация.
|
||||
3. Admin-панель и лимиты.
|
||||
4. Реальный OIDC.
|
||||
5. Multi-company.
|
||||
6. Суммаризация export.
|
||||
|
||||
Почему именно так:
|
||||
|
||||
1. Редактирование уже почти готово и закрывает явный пробел ТЗ.
|
||||
2. Admin-функции сейчас отсутствуют полностью.
|
||||
3. OIDC блокирует продакшн, но не мешает локально добить функциональность.
|
||||
4. Экспорт уже работает как черновой endpoint, но должен быть доведён до суммаризации до релиза.
|
||||
|
||||
## 9. Итог
|
||||
|
||||
Правильный план для этого проекта не “переписать всё правильно”, а “довести уже написанное до требований ТЗ”.
|
||||
|
||||
Текущее состояние проекта:
|
||||
|
||||
1. Data-layer в основном готов.
|
||||
2. Server-layer частично готов.
|
||||
3. UI-layer закрывает только часть client-сценария.
|
||||
4. Auth-layer пока временный.
|
||||
5. Admin-layer почти отсутствует.
|
||||
6. Export-layer не завершён по требованиям агрегации.
|
||||
|
||||
Главный вывод: проект ближе к рабочему состоянию, чем казалось по старым планам, но прошлый план был методологически неверен, потому что не опирался на ТЗ и не различал “не написано” и “написано, но не подключено”.
|
||||
|
||||
Эта часть критична. Её нужно делать одной из первых и сразу покрывать тестами.
|
||||
|
||||
### Поддерживаемый ввод
|
||||
|
||||
1. Одиночный IPv4 адрес, который трактуется как /32.
|
||||
2. IPv4 подсеть в CIDR нотации.
|
||||
|
||||
### Запрещённый ввод
|
||||
|
||||
1. IPv6.
|
||||
2. Доменное имя.
|
||||
3. Маска шире допустимой.
|
||||
4. Любые private или special ranges из приложения А.
|
||||
|
||||
### Правила маски
|
||||
|
||||
Допустимы только /22 ... /32.
|
||||
|
||||
### Нормализация
|
||||
|
||||
Если пользователь ввёл адрес с host-битами, сервис должен:
|
||||
|
||||
1. Нормализовать значение до адреса сети.
|
||||
2. Сохранить нормализованное значение.
|
||||
3. Вернуть пользователю явное сообщение, что адрес был нормализован.
|
||||
|
||||
### Проверки в пределах компании
|
||||
|
||||
1. Запрет полного дубликата активной записи.
|
||||
2. Запрет любого пересечения активной записи с существующими активными записями той же компании.
|
||||
3. Между разными компаниями пересечения допускаются.
|
||||
|
||||
### Список запрещённых диапазонов
|
||||
|
||||
Нужно захардкодить как конфигурацию приложения и покрыть тестами:
|
||||
|
||||
1. 10.0.0.0/8
|
||||
2. 172.16.0.0/12
|
||||
3. 192.168.0.0/16
|
||||
4. 100.64.0.0/10
|
||||
5. 127.0.0.0/8
|
||||
6. 169.254.0.0/16
|
||||
7. 192.0.0.0/24
|
||||
8. 192.0.2.0/24
|
||||
9. 198.51.100.0/24
|
||||
10. 203.0.113.0/24
|
||||
11. 198.18.0.0/15
|
||||
12. 224.0.0.0/4
|
||||
13. 240.0.0.0/4
|
||||
14. 255.255.255.255/32
|
||||
|
||||
## 10. Лимиты и конкурентность
|
||||
|
||||
Это важное место, которого обычно недооценивают.
|
||||
|
||||
### Правила лимитов
|
||||
|
||||
1. Есть глобальный DEFAULT_LIMIT, по умолчанию 15.
|
||||
2. Для компании может быть custom_limit.
|
||||
3. При снижении лимита ниже текущего количества записей существующие записи не удаляются.
|
||||
4. Пока число активных записей больше лимита, новые записи создавать нельзя.
|
||||
|
||||
### Риск гонок
|
||||
|
||||
Если два запроса одновременно создают записи в одной компании, возможны:
|
||||
|
||||
1. Пробитие лимита.
|
||||
2. Пропуск пересечения.
|
||||
3. Пропуск дубликата.
|
||||
|
||||
### Что делать
|
||||
|
||||
Операцию создания и обновления записи нужно делать в транзакции с сериализацией логики на уровне компании. Практически это можно решить так:
|
||||
|
||||
1. Брать advisory lock по company_id перед проверками и записью.
|
||||
2. Либо делать SELECT ... FOR UPDATE по строке компании, если этого достаточно для вашей схемы доступа.
|
||||
|
||||
Для первой версии я бы выбрал advisory lock по company_id. Это проще и надёжнее для бизнес-ограничений, которые нельзя полностью выразить обычным unique index.
|
||||
|
||||
## 11. Экспорт агрегированного списка
|
||||
|
||||
### Требования
|
||||
|
||||
1. В экспорт попадают только активные записи.
|
||||
2. Данные берутся по всем компаниям.
|
||||
3. Пересечения между компаниями допустимы на уровне хранения, но в export должны агрегироваться в минимальный набор CIDR.
|
||||
4. Формат ответа: text/plain.
|
||||
5. Одна строка = один CIDR.
|
||||
|
||||
### Что нужно зафиксировать реализационно
|
||||
|
||||
1. Результат должен быть отсортирован для стабильности.
|
||||
2. В ответе должен быть завершающий перевод строки.
|
||||
3. Content-Type должен быть text/plain; charset=utf-8.
|
||||
4. Желательно отдавать Content-Disposition с понятным именем файла.
|
||||
|
||||
### Защита endpoint
|
||||
|
||||
Если endpoint на старте работает без auth, то доступ надо ограничить минимум одним из способов:
|
||||
|
||||
1. Проверка client IP в приложении.
|
||||
2. Ограничение на reverse proxy.
|
||||
3. Оба сразу.
|
||||
|
||||
## 12. UI-потоки
|
||||
|
||||
### Экран клиента
|
||||
|
||||
Должны быть:
|
||||
|
||||
1. Селектор активной компании, если компаний несколько.
|
||||
2. Таблица записей.
|
||||
3. Индикатор использовано X из N.
|
||||
4. Форма создания записи.
|
||||
5. Возможность редактирования.
|
||||
6. Возможность soft delete.
|
||||
|
||||
### Экран администратора
|
||||
|
||||
Должны быть:
|
||||
|
||||
1. Таблица по всем компаниям.
|
||||
2. Фильтр по компании.
|
||||
3. Фильтр показа удалённых записей.
|
||||
4. Просмотр журнала аудита.
|
||||
5. Управление лимитами компании.
|
||||
|
||||
### UX-детали, которые обязательно сделать
|
||||
|
||||
1. Понятные сообщения об ошибках валидации.
|
||||
2. Явное сообщение о нормализации адреса.
|
||||
3. Явное сообщение о превышении лимита.
|
||||
4. Явное сообщение о пересечении с существующей записью.
|
||||
|
||||
## 13. Пошаговый план реализации
|
||||
|
||||
### Этап 1. Каркас проекта
|
||||
|
||||
1. Создать структуру каталогов.
|
||||
2. Подготовить requirements.txt.
|
||||
3. Подготовить .env.example.
|
||||
4. Подключить FastAPI, Jinja2, static.
|
||||
5. Подготовить docker-compose.yml с PostgreSQL.
|
||||
|
||||
Результат этапа: приложение стартует, открывается базовая страница, есть подключение к БД.
|
||||
|
||||
### Этап 2. Схема БД и миграции
|
||||
|
||||
1. Настроить Alembic.
|
||||
2. Создать initial migration.
|
||||
3. Поднять таблицы companies, whitelist_entries, audit_log.
|
||||
4. Добавить нужные индексы.
|
||||
|
||||
Результат этапа: схема БД фиксирована и воспроизводима.
|
||||
|
||||
### Этап 3. Валидатор CIDR
|
||||
|
||||
1. Реализовать разбор IPv4 и CIDR.
|
||||
2. Реализовать проверку маски.
|
||||
3. Реализовать нормализацию.
|
||||
4. Реализовать проверку запрещённых диапазонов.
|
||||
5. Написать тесты на валидатор.
|
||||
|
||||
Результат этапа: независимый, протестированный модуль бизнес-валидации.
|
||||
|
||||
### Этап 4. Сервисный слой для записей
|
||||
|
||||
1. Реализовать list.
|
||||
2. Реализовать create.
|
||||
3. Реализовать update.
|
||||
4. Реализовать soft delete.
|
||||
5. Реализовать проверки лимитов, дубликатов и пересечений.
|
||||
6. Добавить транзакционную защиту от гонок.
|
||||
|
||||
Результат этапа: бизнес-операции работают без UI.
|
||||
|
||||
### Этап 5. Аудит
|
||||
|
||||
1. Добавить запись CREATE.
|
||||
2. Добавить запись UPDATE со старым и новым состоянием.
|
||||
3. Добавить запись DELETE.
|
||||
4. Добавить интерфейс чтения для admin.
|
||||
|
||||
Результат этапа: все изменяющие действия фиксируются.
|
||||
|
||||
### Этап 6. Авторизация
|
||||
|
||||
1. Реализовать dev-заглушку.
|
||||
2. Реализовать чтение и валидацию JWT из Keycloak.
|
||||
3. Реализовать преобразование claims в current user.
|
||||
4. Реализовать проверки client/admin.
|
||||
5. Реализовать переключение компании.
|
||||
|
||||
Результат этапа: права и контекст пользователя работают сквозным образом.
|
||||
|
||||
### Этап 7. HTML-интерфейс
|
||||
|
||||
1. Реализовать страницу списка.
|
||||
2. Реализовать формы создания и редактирования.
|
||||
3. Реализовать soft delete из UI.
|
||||
4. Реализовать админские экраны.
|
||||
|
||||
Результат этапа: сервис пригоден для ручной эксплуатации.
|
||||
|
||||
### Этап 8. Экспорт
|
||||
|
||||
1. Реализовать сбор всех активных CIDR.
|
||||
2. Реализовать агрегацию.
|
||||
3. Реализовать endpoint export.
|
||||
4. Реализовать сетевое ограничение.
|
||||
|
||||
Результат этапа: внешняя система может забирать текстовый агрегированный whitelist.
|
||||
|
||||
### Этап 9. Финализация
|
||||
|
||||
1. Написать README.
|
||||
2. Подготовить Dockerfile.
|
||||
3. Подготовить пример systemd unit при необходимости.
|
||||
4. Прогнать ручной smoke-test.
|
||||
|
||||
Результат этапа: сервис можно разворачивать и передавать коллегам.
|
||||
|
||||
## 14. Тестовая стратегия
|
||||
|
||||
Минимально обязательные тесты:
|
||||
|
||||
1. Валидный одиночный IPv4 превращается в /32.
|
||||
2. Валидная подсеть принимается.
|
||||
3. Host-биты нормализуются.
|
||||
4. Маски шире допустимой границы отклоняются.
|
||||
5. IPv6 отклоняется.
|
||||
6. Все запрещённые диапазоны отклоняются.
|
||||
7. Дубликат в одной компании запрещён.
|
||||
8. Пересечение в одной компании запрещено.
|
||||
9. Тот же CIDR в другой компании разрешён.
|
||||
10. Soft delete освобождает лимит.
|
||||
11. Export не включает soft-deleted записи.
|
||||
12. Export агрегирует CIDR корректно.
|
||||
13. Client не видит чужие компании.
|
||||
14. Admin видит все компании.
|
||||
15. Аудит создаётся для create, update, delete.
|
||||
|
||||
## 15. Что можно отложить после первой версии
|
||||
|
||||
Это не нужно тащить в MVP:
|
||||
|
||||
1. Полноценный SPA.
|
||||
2. Сложная ORM.
|
||||
3. WebSocket.
|
||||
4. Фоновая очередь.
|
||||
5. Исторические версии записей кроме audit log.
|
||||
6. Автоматическое уведомление по email.
|
||||
|
||||
## 16. Главные риски проекта
|
||||
|
||||
1. Неясный формат claims из Keycloak.
|
||||
2. Гонки при одновременном создании записей.
|
||||
3. Ошибки в трактовке пересечений CIDR.
|
||||
4. Неправильная нормализация адресов без понятного сообщения пользователю.
|
||||
5. Слишком раннее усложнение фронтенда.
|
||||
|
||||
## 17. Что я бы делал первым
|
||||
|
||||
Если начинать реализацию прямо сейчас, порядок такой:
|
||||
|
||||
1. Каркас проекта.
|
||||
2. Схема БД.
|
||||
3. Валидатор и тесты.
|
||||
4. Сервис create/update/delete с транзакциями.
|
||||
5. Только потом UI и Keycloak.
|
||||
|
||||
Это самый безопасный путь: сначала фиксируется ядро бизнес-логики, потом уже внешний слой.
|
||||
|
||||
## 18. Итоговое решение
|
||||
|
||||
За основу реализации стоит брать простой Python/FastAPI сервис с PostgreSQL, синхронной серверной логикой, жёсткой серверной валидацией, транзакционной защитой от гонок и минималистичным HTML UI.
|
||||
|
||||
Главная мысль: сложность здесь не во фронтенде и не в фреймворке, а в корректной реализации правил CIDR, лимитов, ролей и аудита. План должен защищать именно эти части, а не раздувать стек.
|
||||
@@ -1,188 +0,0 @@
|
||||
# План разработки — IP WhiteList Microservice v2
|
||||
|
||||
> **Автор:** GitHub Copilot (Claude Sonnet 4.6)
|
||||
> **Дата:** 2026-05-29
|
||||
|
||||
---
|
||||
|
||||
## Стек
|
||||
|
||||
| Слой | Технология | Обоснование |
|
||||
|---|---|---|
|
||||
| Бэкенд | Python 3.11+ / FastAPI | Коллеги знают Python, авто-документация |
|
||||
| БД | PostgreSQL | Надёжно, поддерживает аудит и сложные запросы |
|
||||
| Работа с БД | psycopg2 + сырой SQL | Проще чем ORM, понятно всем, никакой магии |
|
||||
| Миграции | Alembic | Только для версионирования схемы |
|
||||
| Фронтенд | Jinja2 + обычные HTML-формы | Без JS-фреймворков, минимум зависимостей |
|
||||
| Авторизация | Keycloak OIDC (JWT) | Заглушка только в dev через `.env` флаг |
|
||||
| IP-логика | stdlib `ipaddress` + `netaddr` | Суммаризация CIDR через `netaddr` |
|
||||
|
||||
---
|
||||
|
||||
## Открытые вопросы (нужно прояснить до кодирования)
|
||||
|
||||
1. **Чекбокс администратора** — ТЗ: admin = `clientId == WZ01112` + «отдельный чек-бокс». Что это: отдельный claim в Keycloak-токене (`is_admin: true`)? Роль? Нужно уточнить у команды Keycloak.
|
||||
2. **Создание Company в БД** — когда появляется запись: при первом входе пользователя автоматически, или администратор заводит вручную?
|
||||
3. **Кто потребляет внешний endpoint** — endpoint без авторизации, доступ по IP. Список доверенных IP задаётся конфигом? Nginx ACL?
|
||||
|
||||
---
|
||||
|
||||
## Файловая структура
|
||||
|
||||
```
|
||||
IPWhiteList/
|
||||
├── app/
|
||||
│ ├── main.py # FastAPI app, роутеры, startup
|
||||
│ ├── config.py # Настройки из .env (DEFAULT_LIMIT, DEV_MODE, DB_DSN и др.)
|
||||
│ ├── db.py # psycopg2 connection pool
|
||||
│ ├── models/
|
||||
│ │ └── sql.py # DDL-схема (только для документации, не ORM)
|
||||
│ ├── validators.py # IPv4/CIDR: формат, маска, серые адреса, нормализация
|
||||
│ ├── crud/
|
||||
│ │ ├── entries.py # CRUD whitelist_entries
|
||||
│ │ ├── companies.py # Компании и лимиты
|
||||
│ │ └── audit.py # Запись в audit_log
|
||||
│ ├── auth/
|
||||
│ │ ├── oidc.py # Валидация JWT Keycloak
|
||||
│ │ ├── stub.py # Dev-заглушка (только при DEV_MODE=true)
|
||||
│ │ └── deps.py # FastAPI Depends: current_user
|
||||
│ ├── routers/
|
||||
│ │ ├── entries.py # CRUD UI-роуты + HTMX-фрагменты
|
||||
│ │ ├── admin.py # Аудит, лимиты (только admin)
|
||||
│ │ └── external.py # GET /api/v1/export — txt-файл
|
||||
│ └── cidr_utils.py # Суммаризация через netaddr
|
||||
├── templates/
|
||||
│ ├── base.html
|
||||
│ ├── index.html # Таблица записей + индикатор лимита
|
||||
│ ├── partials/
|
||||
│ │ ├── table.html # HTMX-фрагмент таблицы
|
||||
│ │ └── form.html # Форма создания/редактирования
|
||||
│ └── admin/
|
||||
│ ├── audit.html
|
||||
│ └── limits.html
|
||||
├── static/
|
||||
│ └── style.css
|
||||
├── migrations/
|
||||
│ ├── env.py
|
||||
│ └── versions/
|
||||
├── tests/
|
||||
│ ├── test_validators.py # Юниты для IPv4-валидации (критично!)
|
||||
│ └── test_crud.py
|
||||
├── docs/
|
||||
│ ├── plan.md # LEGACY
|
||||
│ ├── plan-v2.md # Этот файл
|
||||
│ └── WhiteIPlist.docx # Исходное ТЗ
|
||||
├── .env.example
|
||||
├── alembic.ini
|
||||
├── docker-compose.yml # PostgreSQL для dev
|
||||
├── requirements.txt
|
||||
└── README.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Схема БД
|
||||
|
||||
```sql
|
||||
-- Компании (создаются автоматически при первом входе или вручную админом — уточнить)
|
||||
CREATE TABLE companies (
|
||||
id SERIAL PRIMARY KEY,
|
||||
client_id VARCHAR(64) UNIQUE NOT NULL, -- из Keycloak claim
|
||||
name VARCHAR(255),
|
||||
custom_limit INTEGER DEFAULT NULL -- NULL = использовать глобальный DEFAULT_LIMIT
|
||||
);
|
||||
|
||||
-- Whitelist-записи
|
||||
CREATE TABLE whitelist_entries (
|
||||
id SERIAL PRIMARY KEY,
|
||||
company_id INTEGER NOT NULL REFERENCES companies(id),
|
||||
value CIDR NOT NULL, -- нормализованный CIDR
|
||||
comment VARCHAR(255),
|
||||
created_by VARCHAR(255) NOT NULL, -- email из токена
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_by VARCHAR(255),
|
||||
updated_at TIMESTAMPTZ,
|
||||
deleted_by VARCHAR(255),
|
||||
deleted_at TIMESTAMPTZ -- NULL = активная запись
|
||||
);
|
||||
|
||||
-- Аудит (только append, без UPDATE/DELETE)
|
||||
CREATE TABLE audit_log (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_email VARCHAR(255) NOT NULL,
|
||||
company_id INTEGER NOT NULL,
|
||||
action VARCHAR(32) NOT NULL, -- CREATE | UPDATE | DELETE
|
||||
old_value TEXT,
|
||||
new_value TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Индексы
|
||||
CREATE INDEX ON whitelist_entries(company_id) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX ON audit_log(company_id);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Этапы
|
||||
|
||||
### Этап 1 — Каркас + конфиг
|
||||
- [ ] Структура папок
|
||||
- [ ] `requirements.txt`: fastapi, uvicorn, psycopg2-binary, alembic, jinja2, python-jose, netaddr
|
||||
- [ ] `.env.example` со всеми переменными: `DB_DSN`, `DEFAULT_LIMIT=15`, `DEV_MODE=false`, `KEYCLOAK_URL`, `KEYCLOAK_REALM`, `KEYCLOAK_CLIENT_ID`, `ALLOWED_EXPORT_IPS`
|
||||
- [ ] `config.py` — читает `.env`, все параметры типизированы
|
||||
- [ ] `db.py` — psycopg2 connection pool (SimpleConnectionPool)
|
||||
- [ ] `docker-compose.yml` с PostgreSQL
|
||||
|
||||
### Этап 2 — Миграции (схема БД)
|
||||
- [ ] Alembic init
|
||||
- [ ] Initial migration: `companies`, `whitelist_entries`, `audit_log` + индексы
|
||||
- [ ] Проверка `alembic upgrade head`
|
||||
|
||||
### Этап 3 — Валидатор IPv4 (с тестами)
|
||||
- [ ] `validators.py`: принимает строку → возвращает нормализованный CIDR или ошибку
|
||||
- [ ] Проверка формата: одиночный IP или CIDR
|
||||
- [ ] Проверка маски: /22 – /32 (шире /21 — `ValidationError`)
|
||||
- [ ] Нормализация host-битов: `192.168.1.5/24` → `192.168.1.0/24` + флаг `was_normalized=True`
|
||||
- [ ] Запрет серых диапазонов (все из Приложения А ТЗ)
|
||||
- [ ] `tests/test_validators.py` — покрыть все граничные случаи
|
||||
|
||||
### Этап 4 — CRUD-логика
|
||||
- [ ] `crud/companies.py`: get_or_create по client_id, get_limit (custom_limit ?? DEFAULT_LIMIT)
|
||||
- [ ] `crud/entries.py`: список активных, создание (лимит + дубликаты + пересечения), редактирование, soft-delete
|
||||
- [ ] `crud/audit.py`: append-only запись
|
||||
|
||||
### Этап 5 — Авторизация
|
||||
- [ ] `auth/oidc.py` — валидация JWT через JWKS Keycloak, извлечение `clientID`, `email`, определение роли
|
||||
- [ ] Логика роли admin: `clientID == WZ01112` + (claim `is_admin == true` — **уточнить**)
|
||||
- [ ] `auth/stub.py` — только при `DEV_MODE=true`: читает `X-Dev-User` из заголовка
|
||||
- [ ] `auth/deps.py` — `Depends(current_user)` для роутеров
|
||||
|
||||
### Этап 6 — Роутеры + UI
|
||||
- [ ] `routers/entries.py`: список, форма создания, форма редактирования, удаление, переключатель компании
|
||||
- [ ] `routers/admin.py`: журнал аудита, управление лимитами
|
||||
- [ ] Шаблоны Jinja2: base.html, index.html, form.html, admin/audit.html, admin/limits.html
|
||||
- [ ] Индикатор лимита «X из N» на странице
|
||||
- [ ] Уведомление о нормализации адреса пользователю
|
||||
|
||||
### Этап 7 — Внешний endpoint
|
||||
- [ ] `GET /api/v1/export` — только активные записи всех компаний
|
||||
- [ ] Суммаризация через `netaddr.cidr_merge()`
|
||||
- [ ] Ответ: `text/plain`, одна строка — один CIDR
|
||||
- [ ] IP-фильтр из `ALLOWED_EXPORT_IPS` (middleware или Depends)
|
||||
|
||||
### Этап 8 — Деплой
|
||||
- [ ] `Dockerfile` (python:3.11-slim, uvicorn)
|
||||
- [ ] Systemd unit как альтернатива
|
||||
- [ ] Nginx конфиг: reverse proxy + location для static
|
||||
- [ ] README: как поднять с нуля
|
||||
|
||||
---
|
||||
|
||||
## Ключевые принципы
|
||||
|
||||
- **Синхронный код везде** — никакого async/await. FastAPI поддерживает синхронные роутеры.
|
||||
- **Серверная валидация — авторитетная**. Клиентская — только UX.
|
||||
- **`DEV_MODE=true`** — единственный способ обойти Keycloak. В prod недоступен.
|
||||
- **Audit log — append only**. Никаких UPDATE/DELETE в `audit_log`.
|
||||
- **Лимит `DEFAULT_LIMIT`** — всегда из `config.py`, который читает `.env`. Без пересборки.
|
||||
@@ -1,103 +0,0 @@
|
||||
# Рабочий план — IP WhiteList
|
||||
|
||||
> **Автор:** GitHub Copilot (Claude Sonnet 4.6)
|
||||
> **Дата:** 2026-05-29
|
||||
> **Стек:** Node.js + Express + pg + EJS
|
||||
> **Деплой:** nubes_nodejs + nubes_postgres (через веб-кабинет)
|
||||
|
||||
---
|
||||
|
||||
## Что выяснили
|
||||
|
||||
| Факт | Детали |
|
||||
|---|---|
|
||||
| **API шлюз** | `lk-api-gateway.ngcloud.ru`, за DDOS-Guard |
|
||||
| **JWT** | `iss: auth-api`, claims: `ClientID`, `company_id`, `company_name`, `email` |
|
||||
| **Токен для dev** | `secrets.txt`, tech-токен, долгий |
|
||||
| **Валидация JWT** | Шлюз делает сам, нам не нужно |
|
||||
| **Деплой** | Пользователь создаёт инстансы через веб-кабинет |
|
||||
| **isAdmin** | ❓ В JWT нет, нужно уточнить как передавать |
|
||||
| **Мульти-компании** | ❓ В JWT одна компания, список — в authData (localStorage) |
|
||||
|
||||
---
|
||||
|
||||
## Файлы проекта (всё в корне)
|
||||
|
||||
```
|
||||
├── server.js # Express: старт, роуты, middleware
|
||||
├── db.js # pg Pool
|
||||
├── .env.example # DB_DSN, PORT, DEFAULT_LIMIT, DEV_MODE
|
||||
├── package.json
|
||||
├── views/ # EJS-шаблоны
|
||||
│ └── index.ejs # таблица + форма
|
||||
├── public/
|
||||
│ └── style.css
|
||||
├── sql/
|
||||
│ └── schema.sql # CREATE TABLE companies, whitelist_entries, audit_log
|
||||
└── .gitignore
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Схема БД
|
||||
|
||||
```sql
|
||||
companies (id, client_id UNIQUE, name, custom_limit)
|
||||
whitelist_entries (id, company_id FK, value_cidr, comment, created_by, created_at, updated_by, updated_at, deleted_by, deleted_at)
|
||||
audit_log (id, user_email, company_id, action, old_value, new_value, created_at)
|
||||
```
|
||||
|
||||
- `deleted_at IS NULL` = активная запись
|
||||
- `custom_limit IS NULL` = использовать DEFAULT_LIMIT (15)
|
||||
|
||||
---
|
||||
|
||||
## Порядок действий
|
||||
|
||||
### Шаг 1 — Каркас
|
||||
- package.json (express, pg, ejs, dotenv)
|
||||
- server.js (Express, EJS, static, health check `/healthz`)
|
||||
- db.js (pg Pool, `SELECT 1` при старте)
|
||||
- .env.example
|
||||
|
||||
### Шаг 2 — Схема БД
|
||||
- sql/schema.sql
|
||||
- Запустить на своём PG
|
||||
|
||||
### Шаг 3 — Валидатор IPv4
|
||||
- Функция validateCIDR(input) → { cidr, wasNormalized } | error
|
||||
- Правила: /32–/22, запрет серых, нормализация host-битов
|
||||
|
||||
### Шаг 4 — CRUD (сырой pg, без ORM)
|
||||
- Список записей компании
|
||||
- Создание (проверка лимита, дубликатов, пересечений)
|
||||
- Редактирование
|
||||
- Soft-delete
|
||||
|
||||
### Шаг 5 — Auth middleware
|
||||
- DEV_MODE=true: читать заголовок X-Dev-User
|
||||
- PROD: читать JWT из Authorization (шлюз уже проверил)
|
||||
|
||||
### Шаг 6 — UI
|
||||
- Таблица + форма создания/редактирования
|
||||
- Индикатор лимита «X из N»
|
||||
- Сообщения: нормализация, превышение, пересечение
|
||||
|
||||
### Шаг 7 — Аудит
|
||||
- Запись в audit_log при create/update/delete
|
||||
|
||||
### Шаг 8 — Экспорт
|
||||
- GET /api/v1/export — txt, все активные CIDR
|
||||
|
||||
### Шаг 9 — Деплой
|
||||
- Завести nubes_postgres и nubes_nodejs через кабинет
|
||||
- Подключить к API-шлюзу (уточнить процедуру)
|
||||
|
||||
---
|
||||
|
||||
## Что откладываем
|
||||
|
||||
- Keycloak OIDC (шлюз делает)
|
||||
- Полноценный админ-интерфейс
|
||||
- Переключатель компаний (multi-profile)
|
||||
- Тонкая настройка прав
|
||||
@@ -1,107 +0,0 @@
|
||||
# IP WhiteList — План разработки
|
||||
|
||||
> **Дата:** 2026-05-30
|
||||
> **Основание:** [WhiteIPlist.txt](../WhiteIPlist.txt) (ТЗ) + фактический код в [ipwhitelist-app](../../ipwhitelist-app)
|
||||
> **Статус:** каноничный — единственный актуальный план
|
||||
|
||||
---
|
||||
|
||||
## 1. Текущее состояние
|
||||
|
||||
### Готово ✅
|
||||
|
||||
| Слой | Файлы | Что есть |
|
||||
|---|---|---|
|
||||
| БД | `sql/schema.sql` | `companies`, `whitelist_entries` (soft delete), `audit_log` + индексы |
|
||||
| Валидатор | `src/validators.js` | Все 14 запрещённых диапазонов Приложения А, /22–/32, нормализация host-битов, проверка пересечений |
|
||||
| CRUD | `src/queries.js` | `createEntry`, `updateEntry`, `deleteEntry` (soft), `listEntries`, `getExportCIDRs`, `getAudit`, `getLimit` |
|
||||
| UI | `views/index.ejs` | Список, форма добавления, кнопка удаления, стиль Nubes |
|
||||
| Роуты | `server.js` | `GET /`, `POST /add`, `POST /delete/:id`, `GET /export`, `GET /healthz` |
|
||||
|
||||
### Написано в queries.js, но не подключено к роутам
|
||||
|
||||
- `updateEntry` — нет `POST /update/:id`, нет UI редактирования
|
||||
- `getAudit` — нет страницы аудита
|
||||
- `listEntries(companyId, includeDeleted=true)` — флаг есть, не используется
|
||||
|
||||
---
|
||||
|
||||
## 2. Что требует ТЗ, но отсутствует
|
||||
|
||||
| # | Требование | Готовность |
|
||||
|---|---|---|
|
||||
| 1 | Редактирование записи из UI | ❌ queries есть, роута нет |
|
||||
| 2 | Админ-панель (все компании, лимиты, аудит, фильтры) | ❌ |
|
||||
| 3 | OIDC Keycloak вместо base64-заглушки | ❌ |
|
||||
| 4 | Суммаризация CIDR в `/export` | ❌ отдаёт сырой список |
|
||||
| 5 | Клиентская валидация (JS в форме) | ❌ |
|
||||
| 6 | Переключатель компаний (multi-company) | ❌ |
|
||||
| 7 | Просмотр soft-deleted записей админом | ❌ |
|
||||
| 8 | Изменение `custom_limit` для компании | ❌ |
|
||||
|
||||
---
|
||||
|
||||
## 3. Порядок реализации
|
||||
|
||||
### Этап 1 — Пользовательский сценарий (client-flow)
|
||||
|
||||
- [x] Валидатор IPv4/CIDR — полный
|
||||
- [x] Создание / удаление / экспорт
|
||||
- [ ] **Добавить `POST /update/:id`** в `server.js`
|
||||
- [ ] **Добавить inline-форму редактирования** в `views/index.ejs`
|
||||
- [ ] **Клиентская валидация** (JS: формат, маска, длина комментария)
|
||||
|
||||
### Этап 2 — Административная панель
|
||||
|
||||
- [ ] Определение admin-роли (`clientId === 'WZ01112'` + чекбокс)
|
||||
- [ ] `GET /admin` — страница со всеми компаниями
|
||||
- [ ] `POST /admin/limit/:companyId` — изменение custom_limit
|
||||
- [ ] `GET /admin/audit` — журнал аудита
|
||||
- [ ] Фильтр по компании + показ удалённых записей
|
||||
|
||||
### Этап 3 — Авторизация Keycloak OIDC
|
||||
|
||||
- [ ] `npm install openid-client`
|
||||
- [ ] Замена base64-decode на проверку подписи JWT
|
||||
- [ ] Маппинг claims → `req.user` (clientId, email, role)
|
||||
- [ ] Оставить `DEV_MODE` только для локальной разработки
|
||||
|
||||
### Этап 4 — Экспорт и Multi-company
|
||||
|
||||
- [ ] `npm install cidr-tools` — суммаризация в `GET /export`
|
||||
- [ ] Переключатель активной компании (если несколько `clientId` в claims)
|
||||
|
||||
### Этап 5 — Завершение
|
||||
|
||||
- [ ] Автотесты (`jest` + `supertest`)
|
||||
- [ ] Пагинация (если лимит > 50)
|
||||
- [ ] Сверка всех пунктов ТЗ
|
||||
|
||||
---
|
||||
|
||||
## 4. Что НЕ делать
|
||||
|
||||
- ❌ Не переписывать на Python/FastAPI
|
||||
- ❌ Не менять схему БД
|
||||
- ❌ Не переписывать `validators.js` (он полный)
|
||||
- ❌ Не переписывать `queries.js`
|
||||
- ❌ Не делать SPA
|
||||
|
||||
---
|
||||
|
||||
## 5. Зависимости для установки
|
||||
|
||||
```
|
||||
npm install cidr-tools openid-client
|
||||
npm install --save-dev jest supertest
|
||||
```
|
||||
|
||||
Всё остальное — в рамках Express + EJS + pg.
|
||||
|
||||
---
|
||||
|
||||
## 6. Риски
|
||||
|
||||
- **Admin-роль:** неясно как «отдельный чек-бокс» из ТЗ попадает в токен — требует уточнения с командой Keycloak
|
||||
- **Multi-company claims:** ТЗ говорит о нескольких компаниях, но в claims только `clientID` — нужен реальный формат
|
||||
- **IP-ограничение `/export`:** делать в приложении или на уровне ingress — решить при деплое
|
||||
@@ -1,169 +0,0 @@
|
||||
# IP WhiteList — Архитектура (актуально, 2026-05-30 14:43)
|
||||
|
||||
> Ветка `sonnet`, репо: `gitea.services.ngcloud.ru/Nail/ipwhitelist-app.git`
|
||||
> Код: `/home/naeel/ipwhitelist-app`
|
||||
|
||||
---
|
||||
|
||||
## Стек
|
||||
|
||||
| Слой | Технология |
|
||||
|---|---|
|
||||
| Сервер | Node.js + Express 4 |
|
||||
| Шаблоны | EJS (server-side, без SPA) |
|
||||
| БД | PostgreSQL, клиент `pg` (pool) |
|
||||
| Безопасность | helmet, express-rate-limit, csrf-csrf, express-session |
|
||||
| JWT | jsonwebtoken (mock RS256 / OIDC validation) |
|
||||
| Авторизация | Keycloak (OIDC) или mock при локальной разработке |
|
||||
|
||||
---
|
||||
|
||||
## Структура модулей
|
||||
|
||||
```
|
||||
server.js — точка входа: init + подключение роутеров (131 строк)
|
||||
src/
|
||||
auth.js — аутентификация: OIDC или mock, session middleware
|
||||
config.js — MOCK_USERS, backUrl()
|
||||
db.js — pg pool (checkConnection)
|
||||
queries.js — все SQL-запросы к БД
|
||||
validators.js — validateCIDR, aggregateCIDRs
|
||||
middleware/
|
||||
csrf.js — initCsrf() → { doubleCsrfProtection, generateCsrfToken }
|
||||
rateLimit.js — mutationLimiter (30/мин), exportLimiter (20/мин)
|
||||
routes/
|
||||
auth.js — /login /callback /logout /dev-login
|
||||
entries.js — / /add /edit/:id /delete/:id
|
||||
admin.js — /audit /admin /admin/limit/:companyId
|
||||
export.js — /export
|
||||
views/
|
||||
login.ejs — форма входа (mock) или заглушка (OIDC)
|
||||
dev-login.ejs — тестовый вход (пресеты + произвольные поля)
|
||||
index.ejs — список записей пользователя / admin-обзор
|
||||
admin.ejs — admin-панель (все компании, лимиты)
|
||||
audit.ejs — лог операций
|
||||
sql/
|
||||
schema.sql — companies, whitelist_entries (soft delete), audit_log
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Схема авторизации
|
||||
|
||||
```
|
||||
GET /login
|
||||
┌─ OIDC-режим (KC_CLIENT_ID задан) ──────────────────────────────────────┐
|
||||
│ state → сессия │
|
||||
│ redirect → keycloak.nubes.ru/realms/cloud/.../auth │
|
||||
│ ↓ KK перенаправляет на /callback │
|
||||
│ GET /callback → проверить state → exchangeCode → токен KK │
|
||||
│ → userFromPayload → req.session.user → redirect / │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
┌─ Mock-режим (KC_CLIENT_ID не задан) ───────────────────────────────────┐
|
||||
│ render login.ejs (выбрать из MOCK_USERS) │
|
||||
│ POST /login → CSRF → req.session.user → redirect / │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
GET /dev-login (DEV_MODE=true или DEV_SECRET задан)
|
||||
render dev-login.ejs (пресеты MOCK_USERS + произвольные поля)
|
||||
POST /dev-login → CSRF → req.session.user → redirect /
|
||||
⚠ Работает в ОБОИХ режимах — для тестирования с разными ролями
|
||||
|
||||
GET /logout
|
||||
session.destroy() + clearCookie('connect.sid')
|
||||
OIDC: redirect → keycloak logout endpoint
|
||||
Mock: redirect → /login
|
||||
|
||||
Все защищённые роуты: auth.middleware
|
||||
req.session.user → req.user (основной путь)
|
||||
Authorization: Bearer → verify JWT → req.user (API-клиенты)
|
||||
cookie 'jwt' (legacy) → verify → session → req.user (плавная миграция)
|
||||
нет ничего → GET: redirect /login, остальное: 401
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## JWT claims (auth-api, из реального токена портала)
|
||||
|
||||
> auth-api выпускает свой JWT — **не Keycloak напрямую**.
|
||||
> Issuer: `"auth-api"`, алгоритм: RS256.
|
||||
> Наш сервис принимает и валидирует этот токен.
|
||||
|
||||
| Поле | Тип | Пример | Что используем |
|
||||
|---|---|---|---|
|
||||
| `ClientID` | string | `"WZ01325"` | `req.user.clientId` |
|
||||
| `company_id` | UUID | `"3e64aac6-..."` | `req.user.companyId`, FK в БД |
|
||||
| `company_name` | string | `"Тест"` | `req.user.companyName` |
|
||||
| `email` | string | `"user@example.com"` | `req.user.email` (аудит) |
|
||||
| `login` | string | `"user@example.com"` | fallback для email |
|
||||
| `sub` | UUID | `"0199e325-..."` | fallback для companyId |
|
||||
| `iss` | string | `"auth-api"` | проверяется при валидации |
|
||||
| `exp` | number | ~12 часов | автоматически |
|
||||
|
||||
**Признак admin:** `clientId === ADMIN_CLIENT_ID` (env `ADMIN_CLIENT_ID`, default `WZ01112`).
|
||||
`isAdmin` не приходит в JWT — см. [questions.md](questions.md).
|
||||
|
||||
---
|
||||
|
||||
## Маршруты
|
||||
|
||||
| Метод | Путь | Доступ | Лимит |
|
||||
|---|---|---|---|
|
||||
| GET | `/healthz` | public | — |
|
||||
| GET | `/.well-known/jwks.json` | public | — |
|
||||
| GET | `/export` | public | 20/мин |
|
||||
| GET/POST | `/login` | public | — |
|
||||
| GET | `/callback` | public | — |
|
||||
| GET | `/logout` | public | — |
|
||||
| GET/POST | `/dev-login` | public (с guard) | — |
|
||||
| GET | `/` | auth | — |
|
||||
| POST | `/add` `/edit/:id` `/delete/:id` | auth | 30/мин |
|
||||
| GET | `/audit` `/admin` | auth + admin | — |
|
||||
| POST | `/admin/limit/:companyId` | auth + admin | 30/мин |
|
||||
|
||||
---
|
||||
|
||||
## Env-переменные
|
||||
|
||||
### Обязательные в продакшене
|
||||
|
||||
| Переменная | Описание |
|
||||
|---|---|
|
||||
| `DB_HOST` / `DB_PORT` / `DB_NAME` / `DB_USER` / `DB_PASS` | PostgreSQL |
|
||||
| `SESSION_SECRET` | Секрет сессии (≥ 32 символа) |
|
||||
| `CSRF_SECRET` | Секрет CSRF (≥ 32 символа) |
|
||||
| `KC_CLIENT_ID` | client_id в Keycloak realm cloud |
|
||||
| `KC_CLIENT_SECRET` | client_secret |
|
||||
| `APP_URL` | Внешний URL приложения (для redirect_uri) |
|
||||
|
||||
### Опциональные
|
||||
|
||||
| Переменная | Default | Описание |
|
||||
|---|---|---|
|
||||
| `KC_BASE_URL` | `https://keycloak.nubes.ru/realms/cloud` | Keycloak realm base URL |
|
||||
| `ADMIN_CLIENT_ID` | `WZ01112` | WZ-номер администратора |
|
||||
| `PORT` | `3000` | HTTP-порт |
|
||||
| `NODE_ENV` | — | `production` включает secure cookie, trust proxy |
|
||||
| `DEV_MODE` | `false` | `true` → /dev-login без пароля |
|
||||
| `DEV_SECRET` | — | Ключ для /dev-login в staging |
|
||||
|
||||
---
|
||||
|
||||
## БД (dev)
|
||||
|
||||
```
|
||||
Host: write.bde8229b-1381-4330-b24b-727ad73fcb44.dev.nubes.ru
|
||||
DB: ipwhitelist
|
||||
User: super
|
||||
```
|
||||
|
||||
Схема: `sql/schema.sql` — `companies`, `whitelist_entries` (soft delete), `audit_log` + индексы.
|
||||
|
||||
---
|
||||
|
||||
## Деплой (текущий)
|
||||
|
||||
- URL: `https://white.nodejsk8s.dev.nubes.ru`
|
||||
- k8s namespace: `whitelist`
|
||||
- Сессии: in-memory (MemoryStore) — **не масштабируется**, нужен Redis при multi-pod
|
||||
- Режим: ожидает KC_CLIENT_ID/KC_CLIENT_SECRET для перехода с mock на OIDC
|
||||
@@ -1,112 +0,0 @@
|
||||
# Дизайн-система платформы Nubes
|
||||
|
||||
> Извлечено из сохранённой страницы `h.h` (deck-test.ngcloud.ru)
|
||||
|
||||
---
|
||||
|
||||
## Сетка и контейнеры
|
||||
|
||||
| Элемент | Класс | Описание |
|
||||
|---|---|---|
|
||||
| Страница | `navigation:flex`, `services:grid` | Flex/grid на всём |
|
||||
| Карточка | `ui-kit:bg-card ui-kit:rounded-xl ui-kit:border ui-kit:shadow-sm` | Белая карточка, border-radius 12px |
|
||||
| Заголовок карточки | `ui-kit:bg-brand-grey-light ui-kit:px-3 ui-kit:py-3` | Серый фон `#f3f4f6`, padding 12px |
|
||||
| Тело карточки | `ui-kit:px-3 ui-kit:py-3` | padding 12px |
|
||||
|
||||
---
|
||||
|
||||
## Цвета (переменные)
|
||||
|
||||
| Переменная | Назначение | Примерный HEX |
|
||||
|---|---|---|
|
||||
| `--brand-primary` | Основной цвет | `#2563eb` (синий) |
|
||||
| `--brand-gray` | Цвет границ | `#d1d5db` |
|
||||
| `--brand-grey-light` | Фон заголовков | `#f3f4f6` |
|
||||
| `--brand-primary-dark` | Ховер ссылок | `#1d4ed8` |
|
||||
|
||||
---
|
||||
|
||||
## Формы
|
||||
|
||||
```
|
||||
form-table (класс services:):
|
||||
grid-template-columns: fit-content(200px) minmax(200px, 1fr) 0px
|
||||
gap: 8px 16px
|
||||
```
|
||||
|
||||
| Элемент | Класс | Стиль |
|
||||
|---|---|---|
|
||||
| Лейбл | `ui-kit:text-sm ui-kit:leading-none ui-kit:font-normal ui-kit:mb-1 ui-kit:ml-1` | 14px, sans-serif, отступ слева |
|
||||
| Инпут | `ui-kit:h-9 ui-kit:rounded-md ui-kit:border ui-kit:px-3 ui-kit:text-sm` | h=36px, border, padding |
|
||||
| Текстареа | `ui-kit:rounded-md ui-kit:border ui-kit:px-3 ui-kit:py-2 ui-kit:text-sm` | авто-height |
|
||||
|
||||
---
|
||||
|
||||
## Кнопки
|
||||
|
||||
| Тип | Стиль | Класс |
|
||||
|---|---|---|
|
||||
| Обычная | border, bg-white, hover:bg-accent | `ui-kit:border ui-kit:bg-background ui-kit:h-8 ui-kit:rounded-md` |
|
||||
| Удалить | bg-destructive, text-white | `ui-kit:bg-destructive ui-kit:text-white` |
|
||||
| Иконка | size-5 | `ui-kit:size-5 ui-kit:cursor-pointer` |
|
||||
|
||||
Все кнопки: `ui-kit:h-8 ui-kit:rounded-md ui-kit:gap-1.5 ui-kit:px-3`, 14px шрифт.
|
||||
|
||||
---
|
||||
|
||||
## Таблицы
|
||||
|
||||
| Элемент | Стиль |
|
||||
|---|---|
|
||||
| Обёртка | `ui-kit:rounded-md ui-kit:border ui-kit:overflow-hidden` |
|
||||
| Шапка (th) | `ui-kit:bg-brand-grey-light`, uppercase, `ui-kit:py-1 ui-kit:px-2`, border-right |
|
||||
| Ячейка (td) | `ui-kit:px-2 ui-kit:py-1`, border-right, border-bottom |
|
||||
| Строка (tr) | `ui-kit:border-b ui-kit:border-brand-gray-8`, hover: `ui-kit:bg-brand-grey-light/50` |
|
||||
|
||||
---
|
||||
|
||||
## Иконки
|
||||
|
||||
Используются: **Lucide** (`lucide-*`)
|
||||
|
||||
Часто используемые:
|
||||
- `lucide-square-pen` — редактировать
|
||||
- `lucide-rotate-cw` — перезапустить
|
||||
- `lucide-trash` — удалить
|
||||
- `lucide-pause` — остановить
|
||||
- `lucide-play` — запустить
|
||||
- `lucide-save` — сохранить
|
||||
- `lucide-x` — закрыть
|
||||
- `lucide-copy` — копировать
|
||||
- `lucide-check` — успех (зелёный)
|
||||
- `lucide-x` — ошибка (красный)
|
||||
|
||||
---
|
||||
|
||||
## Типографика
|
||||
|
||||
- Основной шрифт: system-ui (Segoe UI, Roboto, etc.)
|
||||
- Размер: `text-sm` = 14px, `text-base` = 16px
|
||||
- Межстрочный: `leading-none` (1), `leading-normal` (1.5)
|
||||
- Цвет текста: `#1a1a1a`
|
||||
- Muted: `text-muted-foreground` = серый `#6b7280`
|
||||
- Заголовки карточек: `font-semibold`, 16px
|
||||
|
||||
---
|
||||
|
||||
## Навигация (слева)
|
||||
|
||||
- Ширина: `var(--sidebar-width)` = 12rem (192px)
|
||||
- Свёрнуто: `var(--sidebar-width-icon)` = 4.5rem (72px)
|
||||
- Верхняя панель: h-20 (80px), border-t-4 border-t-blue-500
|
||||
- Лого: инлайн SVG, 150px ширина
|
||||
|
||||
---
|
||||
|
||||
## Состояния
|
||||
|
||||
| Статус | Цвет |
|
||||
|---|---|
|
||||
| Успех / running | `text-green-500` + иконка `lucide-check` |
|
||||
| Ошибка | `text-red-500` + иконка `lucide-x` |
|
||||
| Предупреждение | `text-amber-500` |
|
||||
@@ -1,96 +0,0 @@
|
||||
# IP WhiteList — План (pending задачи, 2026-05-30 14:43)
|
||||
|
||||
> **Ветка:** `sonnet` · **Обновлено:** 2026-05-30 14:43
|
||||
> Всё что было в старых планах — реализовано. Здесь только то, чего ещё нет.
|
||||
|
||||
---
|
||||
|
||||
## Блокер 1 — Keycloak OIDC (нужны данные от DevOps)
|
||||
|
||||
Код OIDC Authorization Code Flow написан и готов (`src/auth.js`).
|
||||
Не активируется пока не получены:
|
||||
|
||||
| Что нужно | Env-переменная | Статус |
|
||||
|---|---|---|
|
||||
| client_id в realm cloud | `KC_CLIENT_ID` | ❌ ждём DevOps |
|
||||
| client_secret | `KC_CLIENT_SECRET` | ❌ ждём DevOps |
|
||||
| redirect_uri зарегистрирован в KK | (в самом KK) | ❌ ждём DevOps |
|
||||
| SESSION_SECRET для продакшена | `SESSION_SECRET` | ❌ нужно сгенерировать |
|
||||
|
||||
Без этого сервис работает в mock-режиме (`/dev-login` как тестовый backdoor).
|
||||
|
||||
---
|
||||
|
||||
## Блокер 2 — Admin-роль из Keycloak
|
||||
|
||||
Сейчас admin = `clientId === WZ01112`.
|
||||
Как реально приходит `isAdmin` из KK — не ясно (см. [questions.md](questions.md)).
|
||||
После получения ответа — 1–2 строки в `userFromPayload()` в `src/auth.js`.
|
||||
|
||||
---
|
||||
|
||||
## Задача 1 — Деплой ветки sonnet
|
||||
|
||||
- [ ] Передеплоить на `white.nodejsk8s.dev.nubes.ru` (сейчас там master)
|
||||
- [ ] Добавить env-секреты в k8s: `SESSION_SECRET`, `CSRF_SECRET`
|
||||
- [ ] Проверить smoke-тест: `/healthz`, `/login`, `/export`
|
||||
|
||||
---
|
||||
|
||||
## Задача 2 — Сессии при multi-pod
|
||||
|
||||
Сейчас: MemoryStore (in-process, не масштабируется).
|
||||
При нескольких репликах k8s сессии будут теряться.
|
||||
|
||||
- [ ] `npm install connect-redis ioredis`
|
||||
- [ ] Подключить Redis в `server.js` (3 строки конфига)
|
||||
- [ ] Добавить `REDIS_URL` в env
|
||||
|
||||
**Блокер:** нужен Redis в k8s (или принять ограничение на 1 реплику пока).
|
||||
|
||||
---
|
||||
|
||||
## Задача 3 — IP-ограничение /export
|
||||
|
||||
ТЗ: endpoint доступен без авторизации, ограничен по IP на старте.
|
||||
Текущее решение: открытый endpoint с rate-limit (20 req/мин).
|
||||
|
||||
- [ ] Уточнить: ограничение в приложении или ingress? (см. [questions.md](questions.md))
|
||||
- [ ] Если в приложении: whitelist IP через `EXPORT_ALLOWED_IPS` env var
|
||||
|
||||
---
|
||||
|
||||
## Задача 4 — Пагинация
|
||||
|
||||
При `custom_limit` > 50 таблица станет неудобной.
|
||||
Сейчас все записи выводятся без пагинации.
|
||||
|
||||
- [ ] `GET /?page=N` + `LIMIT/OFFSET` в `queries.listEntries()`
|
||||
- [ ] Кнопки Назад/Вперёд в `views/index.ejs`
|
||||
|
||||
Низкий приоритет — текущий дефолтный лимит 15 записей на компанию.
|
||||
|
||||
---
|
||||
|
||||
## Задача 5 — Auto-dismiss alert
|
||||
|
||||
Flash-алерты (ошибка/успех) сейчас исчезают только при перезагрузке.
|
||||
|
||||
- [ ] 10 строк JS в `views/index.ejs`: `setTimeout(() => alert.remove(), 5000)`
|
||||
|
||||
---
|
||||
|
||||
## Не делать (закрыто)
|
||||
|
||||
| Что | Почему |
|
||||
|---|---|
|
||||
| ~~CSRF-защита~~ | ✅ csrf-csrf (double-submit cookie) |
|
||||
| ~~Рефакторинг server.js~~ | ✅ 131 строка, роутеры вынесены |
|
||||
| ~~Автотесты~~ | ✅ 50 unit-тестов (`node tests/run-tests.js`) |
|
||||
| ~~aggregateCIDRs в /export~~ | ✅ в `src/validators.js` |
|
||||
| ~~Admin-панель~~ | ✅ `src/routes/admin.js`, `views/admin.ejs` |
|
||||
| ~~Редактирование записи~~ | ✅ `POST /edit/:id` |
|
||||
| ~~Аудит~~ | ✅ `GET /audit` |
|
||||
| ~~OIDC Authorization Code Flow~~ | ✅ `src/auth.js` + `src/routes/auth.js` |
|
||||
| ~~Session middleware~~ | ✅ `express-session` в `server.js` |
|
||||
| ~~Dev-login backdoor~~ | ✅ `/dev-login` с пресетами + кастомные поля |
|
||||
@@ -1,70 +0,0 @@
|
||||
# Вопросы к DevOps / команде Keycloak — IP WhiteList
|
||||
|
||||
> Обновлено: 2026-05-30 14:43
|
||||
> Часть вопросов из первоначального списка закрыта реализацией.
|
||||
|
||||
---
|
||||
|
||||
## ❌ ОТКРЫТЫЕ (блокируют деплой)
|
||||
|
||||
### 1. Данные клиента Keycloak
|
||||
|
||||
Для активации OIDC-режима (код готов, ждёт переменных):
|
||||
|
||||
```
|
||||
KC_CLIENT_ID= ? # наш client_id в realm cloud
|
||||
KC_CLIENT_SECRET= ? # наш client_secret
|
||||
```
|
||||
|
||||
Redirect URI для регистрации в Keycloak:
|
||||
```
|
||||
https://white.nodejsk8s.dev.nubes.ru/callback
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. Admin-роль: как приходит из Keycloak
|
||||
|
||||
**ТЗ:** Администратор = `clientId = WZ01112` + «отдельный чек-бокс».
|
||||
|
||||
Сейчас: `isAdmin = (ClientID === process.env.ADMIN_CLIENT_ID)` — только WZ01112.
|
||||
|
||||
Вопросы:
|
||||
- Есть ли отдельный claim в JWT для признака admin?
|
||||
- Если да — как называется? Примеры: `realm_access.roles`, `resource_access.whitelist.roles`, `is_admin`, `groups`…
|
||||
- Нужна поддержка нескольких adminов или только WZ01112?
|
||||
|
||||
**Если нет отдельного claim** — оставляем текущее решение, закрываем вопрос.
|
||||
|
||||
---
|
||||
|
||||
### 3. /export — IP-ограничение
|
||||
|
||||
ТЗ: «на старте может работать без авторизации (по сетевому ограничению)».
|
||||
|
||||
Сейчас: открытый endpoint, rate-limit 20 req/мин.
|
||||
|
||||
- Ограничение делаем в **приложении** или в **ingress/nginx**?
|
||||
- Если в приложении — список разрешённых IP (env var `EXPORT_ALLOWED_IPS`?).
|
||||
|
||||
---
|
||||
|
||||
## ✅ ЗАКРЫТЫЕ
|
||||
|
||||
| # | Вопрос | Решение |
|
||||
|---|---|---|
|
||||
| DEV_MODE | Нужен ли dev-режим на платформе? | `/dev-login` — backdoor с `DEV_MODE=true` (без пароля) или `DEV_SECRET=xyz` (с ключом). Работает в обоих режимах. |
|
||||
| Email claim | Как называется claim с email? | Берём `payload.email \|\| payload.login \|\| 'unknown'` |
|
||||
| JWT структура | Формат токена auth-api | Изучен из реального токена (см. [DEPRECATED]-auth-architecture.md). Поля: `ClientID`, `company_id`, `company_name`, `email`, `login`. |
|
||||
| CSRF | Защита POST-форм | csrf-csrf (double-submit cookie pattern) |
|
||||
| Session | Хранение пользователя | express-session (httpOnly, sameSite lax, 8ч) |
|
||||
|
||||
---
|
||||
|
||||
## Условные обозначения в коде
|
||||
|
||||
```js
|
||||
// TODO(KK): уточнить формат claim — см. docs/questions.md#2
|
||||
// FIXME(KK): временно, заменить после ответа команды
|
||||
```
|
||||
|
||||
-31257
File diff suppressed because one or more lines are too long
@@ -1,5 +0,0 @@
|
||||
# research/
|
||||
|
||||
Материалы обратного инжиниринга платформы Nubes — анализ трафика, HAR, токены, схемы взаимодействия.
|
||||
|
||||
В отличие от `docs/` (проектная документация), здесь — результаты исследования внешних систем, которые мы не контролируем.
|
||||
@@ -1,134 +0,0 @@
|
||||
# Сводка код-ревью 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 | Остальное (см. таблицу 🟡) | Качество |
|
||||
@@ -1,82 +0,0 @@
|
||||
# Результаты анализа HAR — nubes_login.har
|
||||
|
||||
> **Дата:** 2026-05-30
|
||||
> **Источник:** `nubes_login.har` — запись входа в платформу Nubes
|
||||
> **Пользователь:** tazet@narod.ru (Наиль Тазетдинов)
|
||||
|
||||
---
|
||||
|
||||
## Схема авторизации
|
||||
|
||||
```
|
||||
Браузер → lk-api-gateway.ngcloud.ru/api/v1/iam/auth/login
|
||||
→ keycloak.nubes.ru/realms/cloud/login-actions/authenticate
|
||||
→ auth-api (выпускает JWT)
|
||||
```
|
||||
|
||||
Платформа использует **собственный auth-api** как надстройку над Keycloak. Токен подписан `auth-api`, не Keycloak.
|
||||
|
||||
---
|
||||
|
||||
## Реальные claim-имена в JWT
|
||||
|
||||
```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",
|
||||
"login": "tazet@narod.ru",
|
||||
"firstname": "Наиль",
|
||||
"middlename": "Фарисович Тестовая учетка",
|
||||
"lastname": "Тазетдинов",
|
||||
"token_type": "access",
|
||||
"realm_access": { "roles": null },
|
||||
"resource_access": { "account": { "roles": null } },
|
||||
"groups": null
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Выводы для IP WhiteList
|
||||
|
||||
### ✅ Точно известно
|
||||
|
||||
| Что | Значение |
|
||||
|---|---|
|
||||
| claim для clientId | `ClientID` (строка, например "WZ01325") |
|
||||
| claim для email | `email` |
|
||||
| claim для company_id | `company_id` (UUID-строка) |
|
||||
| claim для company_name | `company_name` |
|
||||
| Проверка подписи | JWKS от `auth-api`, НЕ `keycloak.nubes.ru` |
|
||||
| Multi-company | В этом токене — одна компания (строка, не массив) |
|
||||
|
||||
### ❓ Ещё не известно
|
||||
|
||||
| Вопрос | Почему важно |
|
||||
|---|---|
|
||||
| Admin-признак | `roles=null`, `groups=null` — нужен пример токена админа |
|
||||
| JWKS URL auth-api | Где брать публичный ключ для проверки подписи? |
|
||||
| Multi-company формат | Будет ли `ClientID` массивом для пользователей с несколькими компаниями? |
|
||||
| Как токен доставляется до сервиса | `Authorization: Bearer`? Или через ingress-заголовки? |
|
||||
|
||||
### Как это использовать сейчас
|
||||
|
||||
В `server.js` middleware уже близок к правильному:
|
||||
|
||||
```js
|
||||
// Текущий код (почти правильный):
|
||||
req.user = {
|
||||
email: payload.email,
|
||||
clientId: payload.ClientID, // ← подтверждено HAR
|
||||
companyName: payload.company_name, // ← подтверждено HAR
|
||||
};
|
||||
```
|
||||
|
||||
Нужно поправить:
|
||||
- `payload.company_id` → сохранять отдельно (UUID, пригодится)
|
||||
- Добавить `TODO` про admin-роль
|
||||
- Добавить `TODO` про JWKS проверку (сейчас base64 без подписи)
|
||||
File diff suppressed because one or more lines are too long
@@ -1,97 +0,0 @@
|
||||
# Код-ревью 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) и отсутствие клиентской валидации из ТЗ.
|
||||
@@ -1,133 +0,0 @@
|
||||
# Код-ревью 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`.
|
||||
@@ -1,141 +0,0 @@
|
||||
# Код-ревью 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-апгрейд.
|
||||
@@ -1,97 +0,0 @@
|
||||
# Код-ревью 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=` не читается в `/`, сырые ошибки БД наружу.
|
||||
@@ -1,67 +0,0 @@
|
||||
# Ответ 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` ограничен 22–32.
|
||||
|
||||
---
|
||||
|
||||
**Итог:** один настоящий эксплуатируемый баг (#1 — обход TEST-NET/special через `/22`), два мелких по строгости парсинга. Главное — поправить блокировку на `overlaps`.
|
||||
@@ -1,49 +0,0 @@
|
||||
# Промпт для Claude Opus 4 — архитектура OIDC/JWT для внешнего auth-api
|
||||
|
||||
## Контекст (не анализируй)
|
||||
|
||||
Делаем микросервис на Node.js + Express. Он стоит ЗА общим auth-api платформы. Платформа сама не наша, мы не можем менять Keycloak.
|
||||
|
||||
**Схема авторизации платформы:**
|
||||
1. Браузер → lk-api-gateway → Keycloak (логин)
|
||||
2. После логина платформа обменивает code на токен через СВОЙ auth-api
|
||||
3. auth-api выпускает JWT с `"iss": "auth-api"` (не Keycloak!)
|
||||
4. Фронтенд платформы хранит access_token в localStorage и шлёт `Authorization: Bearer <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
|
||||
- Только практические варианты, которые можно закодить сейчас
|
||||
- ВЕСЬ ОТВЕТ ОДНИМ БЛОКОМ — без свёрток, без интерактивных элементов
|
||||
@@ -1,245 +0,0 @@
|
||||
# Промпт для Claude Opus 4 — XSS, CSRF, безопасность index.ejs
|
||||
|
||||
## Контекст (не анализируй)
|
||||
Node.js + Express + EJS. Шаблон серверного рендеринга. Данные приходят из БД (email пользователя, CIDR, комментарий) и из query-параметров (error, message). EJS по умолчанию экранирует `<%= ... %>`, НО не экранирует `<%- ... %>`. В этом шаблоне `<%-` не используется.
|
||||
|
||||
## Что нужно
|
||||
Ниже полный `index.ejs`. Найди:
|
||||
- XSS-векторы (экранирование, query-параметры в URL)
|
||||
- CSRF (нет токена в формах)
|
||||
- Clickjacking (отсутствие X-Frame-Options / CSP frame-ancestors)
|
||||
- Утечка данных (видны ли clientId/email других компаний?)
|
||||
- Client-side валидация (отсутствует, хотя ТЗ требует)
|
||||
- Проблемы с disabled-полями (можно ли обойти через DevTools?)
|
||||
|
||||
Ограничения:
|
||||
- Не предлагай менять стек/фреймворк
|
||||
- Только конкретные строки с исправлениями
|
||||
- ВЕСЬ ОТВЕТ ОДНИМ БЛОКОМ — без свёрток, без интерактивных элементов, чистый текст
|
||||
|
||||
```html
|
||||
<!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
@@ -1,168 +0,0 @@
|
||||
# Промпт для Claude Opus 4 — гонки, транзакции, безопасность в queries.js
|
||||
|
||||
## Контекст (не анализируй)
|
||||
Node.js + Express + PostgreSQL (pg pool). Микросервис IP WhiteList. Клиенты создают до 15 доверенных IPv4/CIDR. Многоарендность (изоляция по company_id). Аудит всех изменений обязателен.
|
||||
|
||||
## Что нужно
|
||||
Ниже полный код `queries.js`. Найди:
|
||||
- Race conditions (TOCTOU между проверкой лимита и INSERT)
|
||||
- Отсутствие транзакций там где они нужны
|
||||
- SQL-инъекции
|
||||
- Ошибки изоляции (может ли пользователь компании А затронуть записи компании Б?)
|
||||
- Проблемы с audit_log (пишется ли при ошибках?)
|
||||
|
||||
Ограничения:
|
||||
- Не предлагай менять стек
|
||||
- Только конкретные строки с исправлениями
|
||||
- ВЕСЬ ОТВЕТ ОДНИМ БЛОКОМ — без свёрток, без интерактивных элементов, без ссылок. Чистый текст.
|
||||
|
||||
```js
|
||||
const { pool } = require('./db');
|
||||
const { validate, overlaps } = require('./validators');
|
||||
|
||||
// ── Companies ──
|
||||
|
||||
async function getOrCreateCompany(clientId, companyName) {
|
||||
let res = await pool.query('SELECT * FROM companies WHERE client_id = $1', [clientId]);
|
||||
if (res.rows.length > 0) return res.rows[0];
|
||||
|
||||
res = await pool.query(
|
||||
'INSERT INTO companies (client_id, name) VALUES ($1, $2) RETURNING *',
|
||||
[clientId, companyName || clientId]
|
||||
);
|
||||
return res.rows[0];
|
||||
}
|
||||
|
||||
async function getLimit(company) {
|
||||
const defaultLimit = parseInt(process.env.DEFAULT_LIMIT, 10) || 15;
|
||||
return company.custom_limit || defaultLimit;
|
||||
}
|
||||
|
||||
// ── Entries ──
|
||||
|
||||
async function listEntries(companyId, includeDeleted = false) {
|
||||
let sql = 'SELECT * FROM whitelist_entries WHERE company_id = $1';
|
||||
if (!includeDeleted) sql += ' AND deleted_at IS NULL';
|
||||
sql += ' ORDER BY created_at DESC';
|
||||
return (await pool.query(sql, [companyId])).rows;
|
||||
}
|
||||
|
||||
async function createEntry(companyId, rawValue, comment, userEmail) {
|
||||
const { cidr, wasNormalized } = validate(rawValue);
|
||||
|
||||
// Проверка лимита
|
||||
const company = (await pool.query('SELECT * FROM companies WHERE id = $1', [companyId])).rows[0];
|
||||
const limit = await getLimit(company);
|
||||
const cnt = (await pool.query(
|
||||
'SELECT COUNT(*)::int AS c FROM whitelist_entries WHERE company_id = $1 AND deleted_at IS NULL',
|
||||
[companyId]
|
||||
)).rows[0].c;
|
||||
|
||||
if (cnt >= limit) throw new Error(`Лимит исчерпан: ${cnt} из ${limit}`);
|
||||
|
||||
// Проверка дубликатов и пересечений
|
||||
const existing = (await pool.query(
|
||||
'SELECT value_cidr FROM whitelist_entries WHERE company_id = $1 AND deleted_at IS NULL',
|
||||
[companyId]
|
||||
)).rows;
|
||||
|
||||
for (const row of existing) {
|
||||
if (row.value_cidr === cidr) throw new Error('Такой адрес уже существует');
|
||||
if (overlaps(cidr, row.value_cidr))
|
||||
throw new Error(`Пересечение с существующей записью ${row.value_cidr}`);
|
||||
}
|
||||
|
||||
const res = await pool.query(
|
||||
`INSERT INTO whitelist_entries (company_id, value_cidr, comment, created_by)
|
||||
VALUES ($1, $2, $3, $4) RETURNING *`,
|
||||
[companyId, cidr, comment || null, userEmail]
|
||||
);
|
||||
|
||||
// Аудит
|
||||
await logAudit(userEmail, companyId, 'CREATE', null, cidr, res.rows[0].id);
|
||||
|
||||
return { entry: res.rows[0], wasNormalized };
|
||||
}
|
||||
|
||||
async function updateEntry(entryId, companyId, rawValue, comment, userEmail) {
|
||||
const old = (await pool.query(
|
||||
'SELECT * FROM whitelist_entries WHERE id = $1 AND company_id = $2 AND deleted_at IS NULL',
|
||||
[entryId, companyId]
|
||||
)).rows[0];
|
||||
if (!old) throw new Error('Запись не найдена');
|
||||
|
||||
const { cidr, wasNormalized } = validate(rawValue);
|
||||
|
||||
const existing = (await pool.query(
|
||||
'SELECT value_cidr FROM whitelist_entries WHERE company_id = $1 AND deleted_at IS NULL AND id <> $2',
|
||||
[companyId, entryId]
|
||||
)).rows;
|
||||
for (const row of existing) {
|
||||
if (row.value_cidr === cidr) throw new Error('Такой адрес уже существует');
|
||||
if (overlaps(cidr, row.value_cidr))
|
||||
throw new Error(`Пересечение с существующей записью ${row.value_cidr}`);
|
||||
}
|
||||
|
||||
const res = await pool.query(
|
||||
`UPDATE whitelist_entries SET value_cidr = $1, comment = $2, updated_by = $3, updated_at = NOW()
|
||||
WHERE id = $4 AND company_id = $5 RETURNING *`,
|
||||
[cidr, comment || old.comment, userEmail, entryId, companyId]
|
||||
);
|
||||
|
||||
await logAudit(userEmail, companyId, 'UPDATE', old.value_cidr, cidr, entryId);
|
||||
|
||||
return { entry: res.rows[0], wasNormalized };
|
||||
}
|
||||
|
||||
async function deleteEntry(entryId, companyId, userEmail) {
|
||||
const old = (await pool.query(
|
||||
'SELECT * FROM whitelist_entries WHERE id = $1 AND company_id = $2 AND deleted_at IS NULL',
|
||||
[entryId, companyId]
|
||||
)).rows[0];
|
||||
if (!old) throw new Error('Запись не найдена');
|
||||
|
||||
await pool.query(
|
||||
'UPDATE whitelist_entries SET deleted_by = $1, deleted_at = NOW() WHERE id = $2',
|
||||
[userEmail, entryId]
|
||||
);
|
||||
|
||||
await logAudit(userEmail, companyId, 'DELETE', old.value_cidr, null, entryId);
|
||||
}
|
||||
|
||||
// ── Export ──
|
||||
|
||||
async function getExportCIDRs() {
|
||||
const rows = (await pool.query(
|
||||
'SELECT value_cidr FROM whitelist_entries WHERE deleted_at IS NULL ORDER BY value_cidr'
|
||||
)).rows;
|
||||
return rows.map(r => r.value_cidr);
|
||||
}
|
||||
|
||||
// ── Audit ──
|
||||
|
||||
async function logAudit(userEmail, companyId, action, oldValue, newValue, entryId) {
|
||||
await pool.query(
|
||||
`INSERT INTO audit_log (user_email, company_id, action, old_value, new_value, entry_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)`,
|
||||
[userEmail, companyId, action, oldValue, newValue, entryId || null]
|
||||
);
|
||||
}
|
||||
|
||||
async function getAudit(companyId = null) {
|
||||
let sql = 'SELECT * FROM audit_log';
|
||||
const params = [];
|
||||
if (companyId) {
|
||||
sql += ' WHERE company_id = $1';
|
||||
params.push(companyId);
|
||||
}
|
||||
sql += ' ORDER BY created_at DESC LIMIT 500';
|
||||
return (await pool.query(sql, params)).rows;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getOrCreateCompany, getLimit,
|
||||
listEntries, createEntry, updateEntry, deleteEntry,
|
||||
getExportCIDRs,
|
||||
getAudit,
|
||||
};
|
||||
```
|
||||
@@ -1,59 +0,0 @@
|
||||
# Промпт для Claude Opus 4 — ревью схемы БД
|
||||
|
||||
## Контекст (не анализируй)
|
||||
PostgreSQL. Микросервис IP WhiteList. Многоарендность: у каждой компании (companies) свои записи (whitelist_entries). Лимит по умолчанию 15 активных записей на компанию, custom_limit переопределяет. Soft delete. Аудит всех изменений (audit_log). Сервис на Node.js + pg pool.
|
||||
|
||||
## Что нужно
|
||||
Ниже `schema.sql`. Найди:
|
||||
- Отсутствующие индексы (полный скан таблиц под нагрузкой)
|
||||
- Отсутствующие уникальные constraint'ы (дубликаты на уровне БД, не только в коде)
|
||||
- Проблемы с внешними ключами (каскадное удаление?)
|
||||
- Неоптимальные типы данных
|
||||
- Уязвимости в структуре (можно ли обойти изоляцию через БД?)
|
||||
- Что добавить для production
|
||||
|
||||
Ограничения:
|
||||
- Только конкретные DDL-строки
|
||||
- ВЕСЬ ОТВЕТ ОДНИМ БЛОКОМ — без свёрток, без интерактивных элементов
|
||||
|
||||
```sql
|
||||
-- IP WhiteList schema
|
||||
|
||||
CREATE TABLE IF NOT EXISTS companies (
|
||||
id SERIAL PRIMARY KEY,
|
||||
client_id VARCHAR(64) UNIQUE NOT NULL,
|
||||
name VARCHAR(255),
|
||||
custom_limit INTEGER DEFAULT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS whitelist_entries (
|
||||
id SERIAL PRIMARY KEY,
|
||||
company_id INTEGER NOT NULL REFERENCES companies(id),
|
||||
value_cidr VARCHAR(18) NOT NULL,
|
||||
comment VARCHAR(255),
|
||||
created_by VARCHAR(255) NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_by VARCHAR(255),
|
||||
updated_at TIMESTAMPTZ,
|
||||
deleted_by VARCHAR(255),
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_entries_active
|
||||
ON whitelist_entries(company_id, value_cidr) WHERE deleted_at IS NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_email VARCHAR(255) NOT NULL,
|
||||
company_id INTEGER NOT NULL,
|
||||
action VARCHAR(32) NOT NULL,
|
||||
old_value TEXT,
|
||||
new_value TEXT,
|
||||
entry_id INTEGER,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_company ON audit_log(company_id);
|
||||
```
|
||||
@@ -1,124 +0,0 @@
|
||||
# Промпт для Claude Opus 4 — безопасность server.js (auth, CSRF, XSS)
|
||||
|
||||
## Контекст (не анализируй)
|
||||
Node.js + Express + EJS + PostgreSQL. Микросервис IP WhiteList для облачного провайдера. Аутентификация через внешний auth-api (JWT в Authorization header). Сейчас DEV_MODE=true — временная заглушка. Продакшен с реальными токенами.
|
||||
|
||||
## Что нужно
|
||||
Ниже полный код `server.js`. Найди:
|
||||
- Баги в auth middleware (обход при DEV_MODE, пустой req.user)
|
||||
- CSRF-уязвимости (POST-формы без токенов)
|
||||
- XSS через query-параметры (error в редиректе)
|
||||
- Race conditions на уровне роутов
|
||||
- Проблемы обработки ошибок
|
||||
- Небезопасные HTTP-заголовки
|
||||
- Отсутствующие middleware (helmet, rate-limit, etc.)
|
||||
- Несоответствия ТЗ (где обещано, но не реализовано)
|
||||
|
||||
Ограничения:
|
||||
- Не предлагай менять стек
|
||||
- Только конкретные строки и исправления
|
||||
- ВЕСЬ ОТВЕТ ОДНИМ БЛОКОМ — без свёрток, без интерактивных элементов. Чистый текст.
|
||||
|
||||
```js
|
||||
const express = require('express');
|
||||
const path = require('path');
|
||||
require('dotenv').config();
|
||||
const { checkConnection } = require('./src/db');
|
||||
const q = require('./src/queries');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
const DEV = process.env.DEV_MODE === 'true';
|
||||
|
||||
app.set('view engine', 'ejs');
|
||||
app.set('views', path.join(__dirname, 'views'));
|
||||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
// ── Auth middleware ──
|
||||
app.use((req, res, next) => {
|
||||
if (DEV) {
|
||||
req.user = { email: 'dev@test.local', clientId: 'WZ01325', companyId: null, companyName: 'DEV' };
|
||||
return next();
|
||||
}
|
||||
const auth = req.headers.authorization || '';
|
||||
try {
|
||||
const payload = JSON.parse(Buffer.from(auth.replace('Bearer ', '').split('.')[1], 'base64').toString());
|
||||
req.user = {
|
||||
email: payload.email || 'unknown',
|
||||
clientId: payload.ClientID,
|
||||
companyId: payload.company_id,
|
||||
companyName: payload.company_name || payload.ClientID,
|
||||
};
|
||||
} catch { req.user = {}; }
|
||||
next();
|
||||
});
|
||||
|
||||
// ── Health ──
|
||||
app.get('/healthz', (req, res) => res.send('OK'));
|
||||
|
||||
// ── Главная ──
|
||||
app.get('/', async (req, res) => {
|
||||
const { clientId, companyName } = req.user;
|
||||
try {
|
||||
const company = await q.getOrCreateCompany(clientId, companyName);
|
||||
const limit = await q.getLimit(company);
|
||||
const entries = await q.listEntries(company.id);
|
||||
res.render('index', { entries, limit, used: entries.length, user: req.user, error: null, message: null, wasNormalized: false });
|
||||
} catch (e) {
|
||||
res.render('index', { entries: [], limit: 15, used: 0, user: req.user, error: e.message, message: null, wasNormalized: false });
|
||||
}
|
||||
});
|
||||
|
||||
// ── Создать ──
|
||||
app.post('/add', async (req, res) => {
|
||||
const { value, comment } = req.body;
|
||||
const { clientId, companyName, email } = req.user;
|
||||
try {
|
||||
const company = await q.getOrCreateCompany(clientId, companyName);
|
||||
const result = await q.createEntry(company.id, value, comment, email);
|
||||
const limit = await q.getLimit(company);
|
||||
const entries = await q.listEntries(company.id);
|
||||
res.render('index', {
|
||||
entries, limit, used: entries.length, user: req.user,
|
||||
message: result.wasNormalized ? `Адрес нормализован в ${result.entry.value_cidr}` : 'Добавлено',
|
||||
error: null, wasNormalized: result.wasNormalized,
|
||||
});
|
||||
} catch (e) {
|
||||
const company = await q.getOrCreateCompany(clientId, companyName).catch(() => null);
|
||||
const entries = company ? await q.listEntries(company.id).catch(() => []) : [];
|
||||
const limit = company ? await q.getLimit(company).catch(() => 15) : 15;
|
||||
res.render('index', { entries, limit, used: entries.length, user: req.user, error: e.message, message: null, wasNormalized: false });
|
||||
}
|
||||
});
|
||||
|
||||
// ── Удалить (soft) ──
|
||||
app.post('/delete/:id', async (req, res) => {
|
||||
const { clientId, companyName, email } = req.user;
|
||||
try {
|
||||
const company = await q.getOrCreateCompany(clientId, companyName);
|
||||
await q.deleteEntry(req.params.id, company.id, email);
|
||||
res.redirect('/');
|
||||
} catch (e) {
|
||||
res.redirect('/?error=' + encodeURIComponent(e.message));
|
||||
}
|
||||
});
|
||||
|
||||
// ── Экспорт ──
|
||||
app.get('/export', async (req, res) => {
|
||||
try {
|
||||
const cidrs = await q.getExportCIDRs();
|
||||
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
||||
res.send(cidrs.join('\n') + '\n');
|
||||
} catch (e) {
|
||||
res.status(500).send('Export error');
|
||||
}
|
||||
});
|
||||
|
||||
// ── Старт ──
|
||||
checkConnection()
|
||||
.then(() => console.log('DB connected'))
|
||||
.catch(e => console.error('DB not ready:', e.message));
|
||||
|
||||
app.listen(PORT, () => console.log(`Server on port ${PORT}`));
|
||||
```
|
||||
@@ -1,107 +0,0 @@
|
||||
# Промпт для Claude Opus 4 — код-ревью validators.js
|
||||
|
||||
## Контекст (кратко, не анализируй — просто знай)
|
||||
|
||||
Делаем микросервис IP WhiteList для облачного провайдера. Клиенты управляют доверенными IPv4-адресами через веб-интерфейс. Стек: Node.js + Express + EJS + PostgreSQL.
|
||||
|
||||
Требования ТЗ к валидатору:
|
||||
- Только IPv4, маска /22–/32
|
||||
- Нормализация host-битов (203.0.113.10/24 → 203.0.113.0/24), пользователь должен знать о нормализации
|
||||
- Запрещены диапазоны: RFC1918 (10/8, 172.16/12, 192.168/16), CGNAT (100.64/10), Loopback (127/8), Link-local (169.254/16), IANA special (192.0.0/24), TEST-NET (192.0.2/24, 198.51.100/24, 203.0.113/24), Benchmarking (198.18/15), Multicast (224/4), Reserved (240/4), Limited broadcast (255.255.255.255/32)
|
||||
- Проверка пересечений внутри компании, дубликатов, запрет вложенных подсетей
|
||||
|
||||
## Что нужно
|
||||
|
||||
Ниже код `validators.js`. Твоя задача — найти баги, уязвимости, несоответствия ТЗ и предложить исправления.
|
||||
|
||||
Ограничения:
|
||||
- Не предлагай сменить язык/стек/фреймворк
|
||||
- Не пиши «общие рекомендации» — только конкретные места с номерами строк
|
||||
- Если предлагаешь исправить — напиши точный новый код
|
||||
- Если багов нет — так и скажи
|
||||
- **ВЕСЬ ОТВЕТ ДОЛЖЕН БЫТЬ В ОДНОМ БЛОКЕ — ОДИН markdown-блок, без интерактивных элементов, без свёрток, без ссылок на файлы. Чистый текст, готовый к копированию одной операцией.**
|
||||
|
||||
Файл:
|
||||
|
||||
```js
|
||||
const net = require('net');
|
||||
|
||||
// Приложение А ТЗ — запрещённые диапазоны
|
||||
const BLOCKED_RANGES = [
|
||||
'10.0.0.0/8',
|
||||
'172.16.0.0/12',
|
||||
'192.168.0.0/16',
|
||||
'100.64.0.0/10',
|
||||
'127.0.0.0/8',
|
||||
'169.254.0.0/16',
|
||||
'192.0.0.0/24',
|
||||
'192.0.2.0/24',
|
||||
'198.51.100.0/24',
|
||||
'203.0.113.0/24',
|
||||
'198.18.0.0/15',
|
||||
'224.0.0.0/4',
|
||||
'240.0.0.0/4',
|
||||
'255.255.255.255/32',
|
||||
];
|
||||
|
||||
function validate(input) {
|
||||
const raw = (input || '').trim();
|
||||
if (!raw) throw new Error('Пустое значение');
|
||||
if (raw.includes(':')) throw new Error('IPv6 не поддерживается');
|
||||
if (/[a-zA-Z]/.test(raw.replace(/\./g, '').replace(/\//g, '').replace(/\d/g, '')))
|
||||
throw new Error('Некорректный формат');
|
||||
|
||||
let cidr = raw.includes('/') ? raw : raw + '/32';
|
||||
const [addr, maskStr] = cidr.split('/');
|
||||
const mask = parseInt(maskStr, 10);
|
||||
|
||||
if (isNaN(mask) || mask < 22 || mask > 32) {
|
||||
throw new Error('Маска должна быть от /22 до /32');
|
||||
}
|
||||
if (!net.isIPv4(addr)) throw new Error('Некорректный IPv4 адрес');
|
||||
|
||||
const ipNum = addr.split('.').reduce((acc, octet) => (acc << 8) + parseInt(octet, 10), 0) >>> 0;
|
||||
const netMask = ~((1 << (32 - mask)) - 1) >>> 0;
|
||||
const network = (ipNum & netMask) >>> 0;
|
||||
const networkAddr = [
|
||||
(network >>> 24) & 0xff,
|
||||
(network >>> 16) & 0xff,
|
||||
(network >>> 8) & 0xff,
|
||||
network & 0xff,
|
||||
].join('.');
|
||||
|
||||
const wasNormalized = addr !== networkAddr;
|
||||
const normalized = networkAddr + '/' + mask;
|
||||
|
||||
for (const blocked of BLOCKED_RANGES) {
|
||||
if (isSubnetOf(normalized, blocked)) {
|
||||
throw new Error(`Диапазон ${normalized} запрещён (${blocked})`);
|
||||
}
|
||||
}
|
||||
return { cidr: normalized, wasNormalized };
|
||||
}
|
||||
|
||||
function overlaps(cidr1, cidr2) {
|
||||
const a = cidrToRange(cidr1);
|
||||
const b = cidrToRange(cidr2);
|
||||
return a.start <= b.end && b.start <= a.start ||
|
||||
b.start <= a.end && a.start <= b.start;
|
||||
}
|
||||
|
||||
function isSubnetOf(cidr, parent) {
|
||||
const child = cidrToRange(cidr);
|
||||
const par = cidrToRange(parent);
|
||||
return child.start >= par.start && child.end <= par.end;
|
||||
}
|
||||
|
||||
function cidrToRange(cidr) {
|
||||
const [addr, maskStr] = cidr.split('/');
|
||||
const mask = parseInt(maskStr, 10);
|
||||
const ip = addr.split('.').reduce((acc, o) => (acc << 8) + parseInt(o, 10), 0) >>> 0;
|
||||
const start = ip >>> 0;
|
||||
const end = (ip | ((1 << (32 - mask)) - 1)) >>> 0;
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
module.exports = { validate, overlaps, BLOCKED_RANGES };
|
||||
```
|
||||
Reference in New Issue
Block a user