5b47de0870
Зафиксировано разделение: ручка тестовая по назначению (faucet-TASK.md) и на проде не нужна, однако проверка сети в коде отсутствует, а дефолт SMART_ALLOW_AIRDROP='1' (config.ts) — открыт. Значит 'не нужна' держится памятью деплойщика, а не кодом. Фикс: дефолт '0' + getGenesisHash() в handleFaucet.
11 KiB
11 KiB
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], в закрытую строку добавлять дату и коммит/релиз.
Сводка: открыто 15 — критичных 6 (SEC-1…SEC-6), экономика 3 (SEC-7…SEC-9),
инфраструктура 3 (SEC-10…SEC-12), отложено 3 (SEC-13…SEC-15). Закрыто: 0.
Критично — эксплуатируется
- 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}открыт наружу и выдаёт реальные средства — из двух механизмов внутри ручки (handleFaucet,gateway.ts:395): (а) SOL-налив (fundBettor→requestAirdrop) технически только localnet — mainnet-RPC отвечает410 Gone "RPC call or parameters have been disabled"(проверено вживую); (б) налив фантиков (ensureBettorTokenAccount→mintTo) — НЕ привязан к сети: работает везде, где у шлюза есть mint authority нашего минта (наш8dpXNUgW…— authorityE7Hg…у релейера, проверено). Гейт — только конфиг-флагSMART_ALLOW_AIRDROP, проверки кластера в коде нет (текст «faucet only on localnet» — надпись, не замок). Дополнительно: SOL-барьер обходится — airdrop зовётся лишь при балансе <2 SOL (ops.ts:204), значит адрес с ≥2 SOL пролетает airdrop и доходит прямо доmintTo. Проверено на стенде:curl …/api/faucet/6Q1ko…→{"sol":5,"solLamports":5000000000,"tokenUnits":"10000000000"}. Фикс:SMART_ALLOW_AIRDROP=0вне localnet (per-environment values), дефолт вconfig.tsперевернуть на"0"(сейчасenv("SMART_ALLOW_AIRDROP","1")— открыто по умолчанию), добавить вhandleFaucetпроверку кластера (genesis hash /getGenesisHash()), ручку не публиковать наружу. На боевом мемкойне pump.fun дыра по (б) испаряется сама — у таких токенов mint authority отзывают; но пока на стенде свой минт с нашей authority — она живая, и полагаться на это нельзя. Назначение vs гарантия: ручка задумана как тестовая (ТЗrelayer/faucet-TASK.md: «чтобы Phantom-кошелёк показал баланс > 0») и на проде не нужна — там деньги покупаются через swap, а/betфандит беттора сам (gateway.ts:268-278), так что даже для тестов ручка почти избыточна (нужна лишь для внешнего Phantom на localnet;test-frontеё не вызывает). Но «не нужна» ≠ «не сработает»: держит её память деплойщика, а не код. Наличие ручки в боевом релизе приallowAirdrop: true(helm/values.yaml:22) означает работающий публичный кран, а не безобидный легаси.
Важно — экономика и устойчивость
- SEC-7. Множитель 1.3× требует от дома винрейта ~77 % —
SMART_MULTIPLIER_BPS=13000; порог безубытка = 1/1.3 = 76.9 %. При честной монете дом убыточен, а с дырой окна закрытия игрок забирает близко к 100 %. Фикс: пересчитать экономику под реальный edge. - SEC-8. Нефинализированные ставки запирают средства в ваулте —
vaultне уменьшается, пока ставку не закроют. Наблюдение:vaultBalanceHuman28100 → 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. Фикс: внешний верифицируемый фид.
Отложено — зафиксировать как риск
- 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, nsinvest. Проверить авторизацию публикации в 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