52531dd41a
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>
58 lines
10 KiB
Markdown
58 lines
10 KiB
Markdown
# Аудит безопасности — 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) ✅.
|