Files
pump-game-ops/docs/SECURITY.md
T
subochev 4caddd6a73 docs(security): SEC-6 ЗАКРЫТ — гейт test/release + genesis, начисление вынесено из /bet
Проверено на живом стенде (релизы updown-relayer:2, test-front:3):
- /bet без address -> 400, ничего не начисляет
- /bet на непополненный кошелёк -> 400 insufficient funds
- полный круг POST /wallet -> GET /faucet -> POST /bet -> 200
- smart.mode=release -> 403 'faucet is disabled: only test mode'
- npm test 24/24, мутационная приёмка: ослабление проверки кластеров роняет 2 теста

Остаточный риск: ручка публично маршрутизируется; в бой — mode=release.
2026-09-13 13:39:09 +03:00

14 KiB
Raw Blame History

SECURITY.md — чек-лист безопасности игрового контура

Статус: открыт, ведётся с 2026-09-13. Область: smart-updown-token/relayer (шлюз), контракт smart-updown (program 9ALs…), шина NATS, test-front. Стенд: testfront.binom.pw (k3s ns game).

Аудит проведён на живом стенде, не по чтению кода: воспроизведение эксплойта, проверка ручек из внешнего интернета, проверка аутентификации шины. Фактические выводы приведены в строках.

Пункты нумеруются сквозным SEC-N (не меняется при добавлении/закрытии пунктов) — ссылаться на них номером. Отмечать закрытое — [ ] → [x], в закрытую строку добавлять дату и коммит/релиз.

Сводка: открыто 14 — критичных 5 (SEC-1…SEC-5), экономика 3 (SEC-7…SEC-9), инфраструктура 3 (SEC-10…SEC-12), отложено 3 (SEC-13…SEC-15). Закрыто: 1 (SEC-6, 2026-09-13, релизы updown-relayer:2 / test-front:3).


Критично — эксплуатируется

  • SEC-1. Нет верхней границы окна закрытия ставки — close_bet проверяет только now >= expire_time, но не now <= expire_time + WINDOW. Ставка остаётся open бесконечно, и момент расчёта выбирает тот, кто позвал /close. Место: program/programs/smart-updown/src/instructions/close_bet.rs:60. Фикс: require!(now <= bet.expire_time + CLOSE_WINDOW_SECONDS, Error::CloseWindowExpired).
  • SEC-2. Нет авто-закрытия ставок на сервере — в шлюзе отсутствует таймер закрытия; единственный setInterval в коде — WS-heartbeat. Экспирация ничего не значит, раунд завершает клиент. Место: relayer/src/gateway.ts:621. Фикс: серверный планировщик, закрывающий ставку в момент expire_time.
  • SEC-3. POST /close доступен из интернета без авторизации — любой может закрыть чужую ставку в выбранный им момент. Проверено: curl -X POST https://testfront.binom.pw/api/close -d '{"id":"1"}' → обработчик выполняется. Фикс: авторизация + серверное закрытие.
  • SEC-4. Безрисковый арбитраж на паре ставок UP+DOWN — при entry ≈ X обе ставки закрываются в свой выгодный момент, обе получают payout_done (пара как схема — вывод из дыры окна; прямого прогона пары не делали). Прямо воспроизведено: ставка №5 (DOWN, entry=9998000000) висела open >2 мин после экспирации 15 с и закрыта при exitPrice=9997000000 → {"status":1,"statusName":"payout_done"} — момент расчёта выбран вручную. Фикс: SEC-1 + SEC-2.
  • SEC-5. POST /bet без авторизации — любой может создавать ставки на произвольные адреса и накручивать счётчик раундов global.counter. Проверено: curl -X POST .../api/bet → 200. Фикс: токен/подпись запроса.
  • SEC-6. GET /faucet/{address} открыт наружу и выдаёт реальные средства — ЗАКРЫТ 2026-09-13 (релизы updown-relayer:2, test-front:3). Что было: из двух механизмов ручки (handleFaucet) — (а) SOL-налив (requestAirdrop) технически только localnet (mainnet-RPC отдаёт 410 Gone); (б) налив фантиков (mintTo) — НЕ был привязан к сети: работал везде, где у шлюза есть mint authority минта. Гейтом был только конфиг-флаг SMART_ALLOW_AIRDROP с дефолтом "1" (включено), проверки кластера в коде не было; SOL-барьер обходился (airdrop звался лишь при балансе <2 SOL, ops.ts:204) — адрес с ≥2 SOL доходил прямо до mintTo. Было воспроизведено: curl …/api/faucet/6Q1ko… → {"sol":5,"tokenUnits":"10000000000"} на произвольный адрес без авторизации. Что сделано: (1) новый модуль relayer/src/faucetGate.ts — parseMode + faucetDenyReason с политикой fail-closed: отказ, если режим не test, если genesis-hash недоступен, или если он принадлежит mainnet-beta/devnet/testnet (5eykt4…/EtWTRA…/4uhcVJ…). (2) SMART_MODE (test|release), дефолт release — безопасно забыть, а не вспомнить. (3) handleFaucet: 403 на уровне режима и 403 на уровне сети, в лог faucet DENIED (<причина>). (4) Начисление вынесено из /bet — ставка больше не печатает денег, а address стал обязательным; перед ставкой проверяются балансы (SOL ≥ 0.01, фантиков ≥ суммы). (5) Новая ручка POST /wallet — создать custodial-кошелёк, ничего не наливает. (6) На пульте test-front — отдельный блок «Кошелёк и пополнение» с кнопками «Создать кошелёк» / «Пополнить кошелёк». Проверено на живом стенде: /bet без адреса → 400 "address is required…" (и ничего не начисляет); /bet на непополненный кошелёк → 400 "insufficient funds…"; полный круг POST /wallet → GET /faucet → POST /bet → entryPrice (HTTP 200); гейт в обратную сторону: smart.mode=release → 403 {"error":"faucet is disabled: only test mode (SMART_MODE=test)"} — начисления нет. Тесты: 6 новых проверок гейта, npm test 24/24; мутационная приёмка пройдена (ослабление проверки публичных кластеров → падают ровно 2 теста). Остаточный риск: ручка по-прежнему публично маршрутизируется (/api/* наружу) — гейт теперь в коде, но лучше не выставлять её в интернет. Настройка smart.mode в helm/values.yaml стенда = test; при выкладке в бой обязательно release (или убрать /api/faucet из ингресса). Назначение (историческая справка): ручка задумана как тестовая (ТЗ relayer/faucet-TASK.md: «чтобы Phantom-кошелёк показал баланс > 0»), и на проде она не нужна — там деньги покупаются через swap.

Важно — экономика и устойчивость

  • SEC-7. Множитель 1.3× требует от дома винрейта ~77 % — SMART_MULTIPLIER_BPS=13000; порог безубытка = 1/1.3 = 76.9 %. При честной монете дом убыточен, а с дырой окна закрытия игрок забирает близко к 100 %. Фикс: пересчитать экономику под реальный edge.
  • SEC-8. Нефинализированные ставки запирают средства в ваулте — vault не уменьшается, пока ставку не закроют. Наблюдение: vaultBalanceHuman 28100 → 30100 при незакрытых ставках. Фикс: серверное закрытие + клинап просроченных.
  • SEC-9. Равные exit_price == entry_price не финализируют ставку — close_bet делает return Ok(()), статус остаётся open; при «плоской» цене ставка зависает без исхода. Место: close_bet.rs:66. Фикс: определить политику (продление / возврат / ничья) вместо молчаливого выхода.

Средний приоритет — инфраструктура

  • SEC-10. NATS без аутентификации — auth_required не выставлен, анонимная публикация в шину проходит (проверено на тестовом subject; боевой не трогали). Пока цена условна — терпимо; как только станет денежной, у шины нет прав на запись и любой сможет напечатать свой курс. Фикс: auth + subject-ACL, шлюзу — read-only на market.price.*.
  • SEC-11. Ключ релейера — единственная точка доверия в контракте — контракт не проверяет соответствие цены рынку и принимает любое значение от подписанта (create_bet.rs:75, close_bet.rs:62). Модель «релейер = оракул» стандартна, но компрометация одного ключа E7Hg… = произвольный исход всех ставок. Фикс: on-chain оракул (Pyth/Switchboard) для боя.
  • SEC-12. entry_price не проверяется ни контрактом, ни прод-бэкендом — передаётся в create_bet целиком на усмотрение вызывающего; контракт не может убедиться, что это цена момента bet_time (create_bet.rs:46,75). Проверено на прод-бэкенде smart-updown-server: в GameService.submitBet цена из подписанной tx НЕ сверяется со своим priceSource — payload.entryPrice проходит насквозь; сравнение entryPrice != payload.entryPrice (GameService.kt:100-118) сверяет лишь JSON-подсказку с tx и на расхождении пишет log.warn («trusting the signed transaction»), не отклоняя. Валидируются только paused, парсинг tx, payload.id == global.counter и границы amount. Клиент подписывает entry_price сам → может зашить любое значение (entry_price=1 → UP выигрывает почти всегда). Важная инверсия моделей: сегодня custodia-шлюз стенда эту дыру случайно ЗАКРЫВАЕТ (цена берётся из NATS на сервере, gateway.ts:280), а «правильный» неcustodial прод-бэкенд — ОТКРЫТ. Значит переход на неcustodial-модель (/bet принимает подписанную tx, как smart-updown-server) БЕЗ серверной проверки entry_price по своему оракулу сделает хуже, чем есть. Фикс: внешний верифицируемый фид (Pyth/Switchboard) + серверная сверка entry_price с допуском до ретрансляции.

Отложено — зафиксировать как риск

  • SEC-13. SMART_ALLOW_AIRDROP=1 задеплоен в боевом поде — флаг «только localnet» фактически включён на публичном стенде. Проверка: kubectl get pod -n game … -o jsonpath='{.spec.containers[0].env}'. Фикс: per-environment values, по умолчанию 0.
  • SEC-14. Политика деградации при отсутствии цены не определена — сейчас таймаут PRICE_WAIT_TIMEOUT_MS=15000, ставки просто падают. Место: relayer/src/priceSource.ts:9. Нужно решение: останавливать приём ставок / бэкфилл / заморозка раундов.
  • SEC-15. Аудит binance-market (upstream цены) не проводился — по договорённости источник считается доверенным. Репо subochev/binance-market, ns invest. Проверить авторизацию публикации в NATS и целостность источника.

Ссылки

  • Реализация цены и диаграммы: docs/PRICE-NATS.md, docs/diagrams/price-flow.puml
  • Решения: docs/DECISIONS.md — ADR-006 (предмет ставки), ADR-007 (источник), ADR-008 (реализация)
  • Контракт: program/programs/smart-updown/src/instructions/{create_bet,close_bet}.rs
  • Шлюз: relayer/src/{gateway,natsPrice,priceSource}.ts