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

10 KiB
Raw Blame History

Аудит безопасности — 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) .