Files
ipam_control/docs/reviews/2026-09-26-codebase-review.md
T
ayurishchevandClaude Opus 5.5 13e17fbb47 Задачи 011-024: доработки по ревью кодовой базы и исправление находок
Ревью кодовой базы (docs/reviews/2026-09-26-codebase-review.md) и планы по каждой находке:
011 IP уникален в VRF и хранится в самом узком префиксе (addresses.vrf_id, составной FK
    с каскадом при переносе VRF, миграция 0007 с остановкой на дублях).
012 Ограничение попыток входа (login_attempts, 429 + Retry-After), выравнивание времени
    ответа, журнал без вытеснения анонимными событиями (миграция 0006).
013 Границы пагинации: отрицательные/чрезмерные limit/offset дают 422 вместо 500.
014 Экран адресов: страница свободных адресов арифметикой, пагинация в SQL.
015 Запрет адреса сети/broadcast, загрузка не выше 100 %.
016 Роль по умолчанию — viewer.
017 Проверка JWT_SECRET/ADMIN_PASSWORD при старте.
018 null в PATCH очищает текстовые поля; нейтральный текст конфликта БД.
019 Пакетная загрузка в списках вместо N+1.
020 Автоназначение адреса вне вложенных префиксов, с блокировкой префикса.
021 Advisory-lock при снятии прав администратора, уникальный lower(username) (миграция 0008).
022 Контейнер не от root, healthcheck, блокировка миграций, requirements.lock.
023 Экранирование LIKE, журнал отказов очистки, заголовки безопасности, учёт force-удаления,
    отзыв токенов при смене пароля (claim pv, миграция 0005).
024 Исправление находок ревью 011-023 (docs/reviews/2026-09-26-changes-011-023-review.md):
    сериализация попыток входа, запрет переноса адресов в адрес сети/broadcast, журнал входов,
    запрет смены своего пароля через PATCH, валидация PATCH устройства, обновлён тест токенов.

Тесты: 14 passed. Документация: README.md, docs/changes/011-024, docs/reviews.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 21:33:50 +03:00

161 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Ревью кодовой базы IPAM Manager — 2026-09-26
**Объём:** `app/` (FastAPI, SQLAlchemy, ~1 700 строк), `web/app.js` (~1 000 строк), `alembic/`, `tests/`, Docker-окружение.
**Состояние:** коммит `cd09ef0` (задачи 001–010).
**Метод:** чтение кода. Подозрительные места проверены запросами к стенду `ipam_control_006` на временных данных, которые потом удалены. Выполнен `alembic check`.
Пометки: ✅ — воспроизведено на стенде, 🔎 — вывод по коду.
## Итог
Кодовая база компактная и последовательная. Бизнес-правила собраны в API. Журнал аудита сделан добротно: ротация под advisory-lock, IP клиента с учётом доверенных прокси. В UI все данные сервера экранируются через `esc()`, XSS-векторов не найдено. Миграции совпадают с моделями (`alembic check`: «No new upgrade operations detected»). Все 14 тестов проходят.
Основные риски:
- **Целостность адресного пространства.** Один IP можно завести дважды в одном VRF. Можно назначить адрес сети и broadcast.
- **Устойчивость API.** Отрицательные `limit`/`offset` дают 500. У `offset` на экране адресов нет верхней границы.
- **Журнал можно вытеснить без авторизации.** Неудачные входы пишутся без ограничений, ротация по количеству удаляет старые записи.
| # | Серьёзность | Область | Кратко |
|---|---|---|---|
| 1 | Высокая | Данные | Один IP дважды в VRF (в родителе и в дочернем префиксе); занятость завышается ✅ |
| 2 | Высокая | Безопасность | Анонимный перебор паролей без ограничений и вытеснение журнала аудита 🔎 |
| 3 | Средняя | API | Отрицательные `limit`/`offset` дают 500 ✅ |
| 4 | Средняя | Производительность / DoS | Экран адресов: неограниченный `offset` для `status=free` и выборка без LIMIT 🔎 |
| 5 | Средняя | Данные | Можно назначить адрес сети и broadcast; `free` и загрузка считаются неверно ✅ |
| 6 | Средняя | Безопасность | Роль по умолчанию в `POST /users` — `admin` ✅ |
| 7 | Средняя | Безопасность | Встроенный `jwt_secret = "dev-only-secret"` без проверки при старте 🔎 |
| 8 | Низкая | API | `null` в PATCH адреса даёт 409 «Запись … уже существует» ✅ |
| 9 | Низкая | Производительность | N+1 запросов в списках устройств, операторов, VRF, типов и на «Обзоре» 🔎 |
| 10 | Низкая | Данные | `next_free` (автоназначение адреса) не учитывает вложенные префиксы 🔎 |
| 11 | Низкая | Конкурентность | Нет блокировок в проверке «последний администратор» и в `allocate_next` 🔎 |
| 12 | Низкая | Эксплуатация | Контейнер работает от root, нет healthcheck у `app`, миграции выполняются при старте каждой реплики 🔎 |
| 13 | Низкая | Мелочи | LIKE без экранирования, нет журналирования неудачных попыток очистки журнала, нет заголовков безопасности 🔎 |
---
## Находки
### 1. Один IP-адрес дважды в VRF ✅
`create_address` (`app/api/v1/prefixes.py:314`) проверяет две вещи: адрес входит в префикс и уникальна пара `(prefix_id, address)` (`app/models.py:119`). Уникальности адреса в пределах VRF нет. Кроме того, адрес можно записать на родителя, даже если он попадает в диапазон дочернего префикса.
**Воспроизведение:** родитель `198.51.100.0/24`, дочерний `/25`. `198.51.100.5` создаётся и в родителе, и в дочернем (оба ответа 201). У родителя `used = 4` при трёх уникальных адресах.
**Последствия:** дубли в реестре. `_usage` суммирует адреса поддерева, поэтому загрузка и «Обзор» завышаются.
**Рекомендация:**
- Хранить адреса только в самом узком префиксе. При создании проверять, что адрес не попадает ни в один дочерний префикс.
- Добавить уникальность `(vrf_id, address)`: денормализовать `vrf_id` в `addresses` или поставить триггер. Иначе инвариант держится только в коде.
- При создании префикса (`attach_to_tree`) переносить в новый дочерний адреса родителя из его диапазона.
### 2. Перебор паролей и вытеснение журнала без авторизации 🔎
`POST /auth/login` (`app/api/v1/auth.py:14`) никак не ограничен. Каждая неудача пишет `session.failed` в `audit_log`. Ротация по количеству (`app/rotation.py:42`) удаляет самые старые записи сверх `max_entries` (по умолчанию 100 000).
**Последствия:**
- неограниченный перебор паролей;
- анонимный клиент может за несколько минут вытеснить из журнала историю реальных изменений.
Попутно: при несуществующем логине argon2 не вызывается, поэтому по времени ответа можно понять, существует ли логин. У `LoginIn` нет ограничения длины пароля.
**Рекомендация:**
- ограничить частоту попыток по IP и логину (таблица попыток по образцу `ClearAttempt` или прокси);
- при серии неудач агрегировать их в одну запись журнала;
- выполнять фиктивную проверку argon2 для несуществующего логина;
- ограничить `password` до `max_length=128`;
- рассмотреть отдельную квоту ротации для событий `session.*`.
### 3. Отрицательные `limit`/`offset` дают 500 ✅
У всех списков объявлено `limit: int = Query(…, le=…)` и `offset: int = 0` без нижней границы. `GET /audit?limit=-1` и `GET /isps?offset=-1` возвращают **500**: PostgreSQL отвергает отрицательный LIMIT/OFFSET.
**Рекомендация:** `Query(…, ge=1, le=…)` и `Query(0, ge=0)` во всех списках (`refs.py`, `prefixes.py`, `journal.py`, `users.py`). Удобно завести общую зависимость пагинации.
### 4. `GET /prefixes/{id}/addresses`: ресурсоёмкие ветки 🔎
В `list_addresses` (`app/api/v1/prefixes.py:272`) две проблемы.
- При `status=free` цикл собирает `offset + limit` свободных адресов в список (`need = offset + limit`). У `offset` нет верхней границы. На IPv6-префиксе любой пользователь с ролью `viewer` одним запросом с `offset=10^8` занимает процессор и память воркера.
- Без фильтров основной запрос (строка 289) выполняется **без LIMIT/OFFSET**: все адреса префикса загружаются и нарезаются в Python. На крупных подсетях это десятки тысяч объектов на каждую страницу.
**Рекомендация:**
- для `free` генерировать нужную страницу арифметически, пропуская занятые диапазоны, без материализации `offset` элементов, и ограничить `offset`;
- в ветке без свободных адресов перенести пагинацию в SQL;
- смешанный режим (занятые + свободные) оставить только для подсетей ≤ `FREE_LISTING_LIMIT`, как сейчас.
### 5. Назначаются адрес сети и broadcast ✅
`create_address` проверяет только `ip in network`. В `198.51.100.0/25` успешно назначаются `.0` и `.127`. При этом `capacity()` для IPv4 до `/30` вычитает эти два адреса. В итоге `free = cap - stored` занижается, а загрузка может превысить 100 %.
**Рекомендация:** для IPv4 с длиной ≤ 30 отклонять адрес сети и broadcast (422). Вариант — считать их занятыми только в `capacity`, но это хуже.
### 6. Роль по умолчанию — администратор ✅
`UserIn.role` по умолчанию `Role.admin` (`app/schemas.py:79`), и `POST /users` без `role` создаёт администратора. UI передаёт `viewer` явно, но у клиентов API и скриптов по умолчанию наибольшие права.
Та же картина в модели: `User.role` — `default=Role.admin`.
**Рекомендация:** по умолчанию `viewer`.
### 7. Встроенный JWT-секрет 🔎
`Settings.jwt_secret = "dev-only-secret"` (`app/config.py:8`). Если приложение запущено без `JWT_SECRET` (локально или другим способом развёртывания), токены подписываются общеизвестным ключом, и их можно подделать для любого логина.
В docker-compose пустое значение даёт не небезопасный режим, а 500 при входе: PyJWT отвергает пустой HMAC-ключ.
**Рекомендация:** убрать значение по умолчанию. При старте проверять длину секрета, например ≥ 32 байт, и не запускаться без него. То же для `ADMIN_PASSWORD` при пустой БД: сейчас без него админ молча не создаётся.
### 8. `null` в `PATCH /addresses/{id}` даёт ложный 409 ✅
`update_address` использует `model_dump(exclude_unset=True)` без `exclude_none`, поэтому `{"description": null}` пишет NULL в колонку NOT NULL. Приходит `IntegrityError`, и ответ — 409 «Запись с такими значениями уже существует».
Остальные PATCH-эндпоинты используют `exclude_none=True`.
**Рекомендация:** `exclude_none=True`. Если нужна очистка поля, явно приводить `None` к `""`.
### 9. N+1 запросов 🔎
Счётчики и связанные объекты догружаются отдельными запросами на каждую строку:
- `_device_out`: 2 запроса на устройство, до 1 000 на странице из 500;
- `_isp_out`: организация на каждого оператора;
- `_vrf_out`, `_type_out`, `_org_out`: счётчики на каждую строку;
- `_prefix_outs` на «Обзоре»: `_depths` и `_capacities` по каждой организации.
При текущих объёмах это незаметно, но растёт линейно.
**Рекомендация:** агрегаты одним `GROUP BY` на страницу и `selectinload` для связей.
### 10. Автоназначение адреса не учитывает вложенные префиксы 🔎
`next_free` (`app/services.py:309`) исключает только адреса самого пула. Если внутри пула есть дочерний префикс, выданный адрес может попасть в его диапазон. Это частный случай п. 1.
Кроме того, функция перебирает хосты подряд и загружает в память все занятые адреса. Для крупных пулов лучше использовать тот же приём, что и в `next_free_subnet` (перескок за занятые диапазоны).
### 11. Гонки без блокировок 🔎
- **Последний администратор.** `update_user` и `delete_user` проверяют «последний активный администратор» через `count()` без блокировки. Два администратора, одновременно отключающие друг друга, могут оставить систему без админа. Нужен `SELECT … FOR UPDATE` по активным администраторам или advisory-lock.
- **Автоназначение адреса.** В `allocate_next` при параллельных запросах один из них получает 409 «повторите запрос». Корректно, но можно блокировать префикс так же, как в `allocate_subnet`.
- **Логин без учёта регистра.** Уникальность проверяется запросом, а в БД `username` уникален с учётом регистра. Нужен индекс `unique(lower(username))`, как у VRF.
### 12. Эксплуатация 🔎
- `Dockerfile`: процесс работает от root. Нет `USER`, нет `HEALTHCHECK`, у сервиса `app` в compose нет `healthcheck`, хотя `/healthz` есть.
- `alembic upgrade head` в `CMD` выполняется при старте каждой реплики. При масштабировании нужен отдельный job или advisory-lock в `env.py`.
- Приложение слушает `0.0.0.0:8088` по HTTP, JWT и пароли идут открытым текстом по LAN. Для эксплуатации нужен TLS-прокси (Caddy на хосте уже есть) и `TRUSTED_PROXIES`.
- Зависимости заданы диапазонами без lock-файла, поэтому сборки не воспроизводимы.
### 13. Мелочи 🔎
- **LIKE без экранирования.** В `list_prefixes`, `list_users` и `_like()` (`refs.py`) `%` и `_` в поиске не экранируются (в журнале это сделано, `_escape_like`).
- **Очистка журнала.** Неудачные попытки подтверждения пароля пишутся только в `clear_attempts`, в журнал они не попадают, хотя событие значимо для безопасности.
- **Ответы без заголовков безопасности.** Нет `Content-Security-Policy`, `X-Frame-Options`, `X-Content-Type-Options`, поэтому UI можно встроить во фрейм.
- **Удаление префикса с `force=true`.** Запись журнала не содержит числа удалённых вместе с ним адресов.
- **Хранение токена.** Токен лежит в `sessionStorage`: при XSS его можно украсть. XSS сейчас не найден, но CSP снизил бы риск.
- **Смена пароля.** Уже выданные токены не отзываются (задокументировано). Можно хранить `password_changed_at` и сверять с `iat` токена.
---
## Что сделано хорошо
- **Целостность VRF и организации.** Обеспечена в БД: составной FK `fk_prefixes_vrf_org`, уникальный индекс `lower(name)`.
- **Ошибки API.** Единый формат `{"code", "message", "fields"}`. Нарушения ограничений БД переводятся в 409, а не в 500.
- **Журнал.** Ротация под `pg_try_advisory_xact_lock`. IP берётся из `X-Forwarded-For` только от доверенных прокси, разбор идёт справа налево. Мусор в заголовке игнорируется.
- **Пароли.** Хранятся в argon2. Очистка журнала подтверждается паролем с блокировкой после пяти неудач. Отключение учётной записи действует немедленно.
- **UI без сборки и зависимостей.** Всё, что приходит с сервера, экранируется, `innerHTML` используется только с экранированным содержимым.
- **Тесты интеграционные, по реальному стеку.** Они короткие и проверяют бизнес-правила, а не реализацию.
## Планы доработок
Одна находка — один план в `docs/changes/`:
| № | План |
|---|---|
| 1 | `011-address-unique-in-vrf` |
| 2 | `012-login-rate-limit` |
| 3 | `013-pagination-bounds` |
| 4 | `014-addresses-listing-performance` |
| 5 | `015-network-broadcast-addresses` |
| 6 | `016-default-role-viewer` |
| 7 | `017-jwt-secret-validation` |
| 8 | `018-patch-null-handling` |
| 9 | `019-n-plus-one-queries` |
| 10 | `020-next-free-address` (после 011) |
| 11 | `021-concurrency-locks` |
| 12 | `022-ops-hardening` |
| 13 | `023-minor-hardening` |
## Предлагаемый порядок работ
1. **Быстрые правки, около часа, без миграций:** пп. 3, 5, 6, 8, экранирование LIKE из п. 13.
2. **Безопасность:** п. 2 (ограничение частоты входа, агрегирование неудач в журнале), п. 7 (проверка секрета при старте), п. 12 (non-root, healthcheck, TLS через прокси).
3. **Целостность данных:** пп. 1 и 10 — модель «адрес в самом узком префиксе» и уникальность в VRF. Нужны миграция и проверка существующих дублей.
4. **Производительность:** пп. 4 и 9, когда объёмы станут заметными. Ограничение `offset` из п. 4 стоит сделать сразу вместе с п. 3.