Files
netrunner-backend/SECURITY_AUDIT.md
T
nineap 52531dd41a Unify auth into refresh-token cookie sessions; server-authoritative nodes and Cyberhack
Auth was effectively broken for every login path: the Telegram handler
built a Set-Cookie header but never attached it to the response, and
Ghost Protocol (seed) login never set a cookie at all — so billing, the
game, and admin actions never actually worked once the frontend was fixed
to stop trying to read an HttpOnly cookie from JS. Replaced the bare
10-minute JWT with AuthService::issue_session: a 15-minute access cookie
plus a rotating 30-day refresh cookie (opaque token, only its SHA-256
hash stored in the new refresh_sessions table); reusing an already-
rotated refresh token now revokes every session for that user. CSRF/CORS
origin allowlists moved from a single hardcoded domain to env-configured
lists so the same session works across the landing and account subdomains.
The bot's WebApp deep links (Личный Кабинет/Тарифы/Синдикат) now bridge
through a short-lived bootstrap token exchanged via POST /auth/exchange
instead of a bare access token in the URL.

Nodes: /nodes/routing now serializes the tunnel_* fields, public_key,
sni_domain and a one-time session token gated on an active subscription
instead of a stub ip/port/protocol list; node provisioning returns 202
immediately and finishes in the background instead of blocking the
request for the whole SSH run; a new background task TCP-pings nodes
every 60s and flips online/offline itself; admin can now force-restart a
node over SSH.

Billing: TON exchange rate is cached in Redis (60s) instead of hitting
TonAPI on every invoice, and the request/response now actually uses the
requested currency and TonAPI's real (uppercase) key casing — previously
only USD/RUB were ever requested and the lookup used the wrong case, so
non-USD/RUB plans could never price correctly.

Cyberhack: the server now generates and stores the board itself and
replays the client's raw click path to compute the score, instead of
clamping a client-reported number — closes the "final score is whatever
the browser sends" hole flagged in the security audit.

Frontend: api.ts no longer tries to read the HttpOnly session cookie from
JS (that never worked) and instead relies on same-origin credentials plus
a silent refresh-and-retry on 401; AdminDashboard's restart/delete actions
are wired up; Auth.tsx drops the artificial delays and requires an
explicit seed download/confirmation before continuing, since a lost Ghost
seed is unrecoverable.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 15:29:51 +07:00

58 lines
10 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.
# Аудит безопасности — Netrunner Control Plane
## Проход 2026-07-04 — единая авторизация, анти-чит Cyberhack
Скоуп: `netrunner-backend` (auth, nodes, users, billing) + `netrunner-backend/frontend` +
`netrunner-landing` + `Cyberhack`.
| # | Severity | Что | Где |
|---|----------|-----|-----|
| 1 | **High** | **Сессия нигде реально не работала.** `auth_telegram_api` строил `Set-Cookie`-заголовок, но никогда не прикладывал его к ответу (возвращал голый `Json`, `HeaderMap` отбрасывался); `POST /auth/seed/generate`/`seed/login` вообще не ставили cookie. `frontend/src/api.ts::getTokenCookie()` читал `document.cookie` в расчёте на токен, который сам же сервер помечал `HttpOnly` — то есть прочитать его через JS физически невозможно. Итог: билинг, игра и админка в ЛК не работали ни для одного способа входа. Введена единая точка выдачи сессии (`AuthService::issue_session`), фронт полностью перешёл на `credentials: same-origin` без чтения токена из JS. | `auth/controller.rs`, `frontend/src/api.ts` |
| 2 | **High** | **`localStorage` для сессионного токена** на лендинге (`netrunner-landing`) и в Tauri-приложении — ровно то, чего требовалось избежать. Лендинг переведён на ту же HttpOnly cookie-сессию через `credentials: "include"` и общий родительский домен (`Domain=.netrunner-vpn.com`); `netrunner-app` вне скоупа этого прохода (её `localStorage`-черновик отключён и не используется в рантайме). | `netrunner-landing/app/[lang]/auth/auth-client.tsx` |
| 3 | **Medium** | **Отсутствие refresh-флоу**: access-JWT жил 10 минут без какого-либо обновления — юзера тихо разлогинивало каждые 10 минут. Мёртвая таблица `auth_sessions` (недописанный Magic Link) repurposed под `refresh_sessions`: ротируемый непрозрачный refresh-токен (30 дней, только SHA-256-хеш в БД), при обнаружении повторного использования уже отозванного токена — отзыв **всех** сессий юзера (сигнал компрометации). | `auth/service.rs`, `migrations/0002_refresh_sessions.sql` |
| 4 | **Medium** | **Cyberhack: сервер доверял `score` от клиента** (клампился 0..=500, не более). Теперь доску генерирует и хранит сервер (`users/cyberhack.rs`, порт алгоритма 1:1 из `Cyberhack/src/lib.rs`), клиент присылает только сырой путь кликов, сервер сам реплеит его и валидирует легальность каждого хода; повторная сдача той же сессии отклоняется (`used_at`). | `users/cyberhack.rs`, `users/service.rs`, `migrations/0003_hack_sessions.sql` |
| 5 | **Low** | **CSRF/CORS были захардкожены на один домен** (`https://netrunner-vpn.com`) — не пропускали легитимные cookie-запросы с сабдомена ЛК (`account.netrunner-vpn.com`). Вынесены в конфигурируемые списки (`CSRF_ALLOWED_ORIGINS`, `CORS_ALLOWED_ORIGINS`). | `auth/guard.rs`, `api.rs` |
| 6 | **Low** | **Курс TON — TonAPI отдаёт валюты в ВЕРХНЕМ регистре** (`prices.USD`), код сверял по нижнему (`prices.usd`) и хардкодил запрос только `currencies=usd,rub` — курс для остальных валют из `plan_prices` (CNY/TRY/IRR) не находился никогда. Заодно добавлено кеширование в Redis (TTL 60с). | `billing/service.rs::initiate_payment` |
## Проход 2026-06-26
## Исправлено в этом проходе
| # | Severity | Что | Где |
|---|----------|-----|-----|
| 1 | **High** | **Sybil-абуз триала**: анонимные Ghost-аккаунты (вход по Seed) штампуются бесплатно и без лимита, каждый получал бесплатный 3-дневный триал ⇒ бесконечный бесплатный VPN скриптом. Теперь триал только для аккаунтов с привязкой к Telegram (`tg_id`). | `billing/service.rs::activate_trial` |
| 2 | **Medium** | **Timing-атака на Telegram-аутентификацию**: HMAC-тег сравнивался обычным `!=` по hex-строке (выход на первом несовпавшем байте → утечка по таймингу). Заменено на постоянное по времени `mac.verify_slice`. | `auth/service.rs::verify_tg_widget_auth` |
| 3 | **Medium** | **CSRF-модель**: проверка Origin/Referer применялась ко всем мутирующим запросам, включая Bearer-токен (для которого CSRF неактуален) — это и ломало приложение, и неверно по модели. Теперь CSRF проверяется только для cookie-сессий; Bearer-клиенты не блокируются. | `auth/guard.rs::auth_guard` |
| 4 | **Low** | **Инвойс без проверки владения**: `confirm_payment` игнорировал юзера (`_user` + TODO), позволяя инициировать обращения к блокчейну по произвольным `invoice_id` (перебор/DoS). Добавлена проверка владельца. | `billing/service.rs`, `billing/controller.rs` |
| 5 | **Low** | **Паники в провижининге нод**: `Session::new().unwrap()`, `channel_session().unwrap()`, `read_to_string().unwrap()`, `wait_close().unwrap()`, `GHCR_TOKEN.expect()` → корректные `AppError`. Отсутствие `init_node.sh` теперь жёсткая ошибка, а не деплой полуживой ноды через `echo`-заглушку. | `nodes/service.rs` |
| 6 | **Low** | **Недописанная диагностика Cyberhack**: сервис маппил ошибку `"TOO_FREQUENT"`, которую репозиторий никогда не возвращал (и нет-билетов, и слишком-часто молча давали 0). Репозиторий теперь блокирует строку (`FOR UPDATE`) и возвращает точные причины (`NO_TICKETS` / `TOO_FREQUENT` / `NO_USER`). | `db/repository.rs`, `users/service.rs` |
## Проверено и признано безопасным
- **SQL-инъекции** — нет: все запросы параметризованы (`bind`), динамической конкатенации SQL из пользовательского ввода не найдено.
- **Гонки в экономике** — защищены БД-инвариантами: `referrals UNIQUE(referred_id)`, `promo_usages UNIQUE(promo_id, user_id)`, `invoices.external_id UNIQUE`, `subscriptions PK(user_id)`. Промокоды берут строку под `FOR UPDATE`. Двойное списание билета закрыто `FOR UPDATE`.
- **Подмена цены/тарифа** — нет: `initiate_payment` берёт цену из `plan_prices` по серверу, сумму считает сам; клиент не влияет на сумму инвойса.
- **Подтверждение оплаты** — `verify_payment` проверяет on-chain сумму (`>=`), адрес назначения и комментарий (`== invoice_id`); статус `paid` идемпотентен; один tx-хеш нельзя переиспользовать (UNIQUE `external_id`).
- **Раскрытие ошибок** — наружу уходят общие сообщения, детали только в логи; секреты (JWT, bot token) не логируются.
- **Self-referral** — заблокирован на этапе `process_login` (`referrer_tg != tg_id`).
## Остаточные риски (вынесено отдельно)
- **`GET /nodes/routing` выдаёт `public_key`/`session_token` в контрактном виде**,
которые ещё нужно консистентно проверять на стороне ноды (`netrunner-proxy`,
отдельный репозиторий, использует ephemeral ECDH per-session, не статический
публичный ключ) — само по себе выпускается корректно, но полная цепочка
верификации на стороне прокси вне скоупа этого прохода.
- **`netrunner-app` (Tauri)** по-прежнему содержит неиспользуемый `localStorage`-черновик
Ghost Protocol в `src/lib/api.ts` — код мёртвый (`USE_STATIC_NODES=true`), но если его
когда-нибудь включат как есть, он снова принесёт токен в `localStorage`. Вне скоупа
этого прохода по решению заказчика.
- **Health-check нод — только TCP-connect**, не полноценная проверка протокола
(нода может слушать порт, но быть неработоспособной на уровне приложения).
## Гейты
`cargo check` ✅ · `cargo clippy --all-targets -- -D warnings` ✅ · `cargo fmt --check`
· `cargo test` (юнит + `tests/db_integration_test.rs` на живом Postgres) ✅, кроме одного
предсуществующего несвязанного мисматча (`test_consume_ticket_and_reward`, заведена
отдельная задача) · сквозная ручная проверка через `make dev` (auth/nodes/billing/hack) ✅.