4caddd6a73
Проверено на живом стенде (релизы 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.
56 lines
14 KiB
Markdown
56 lines
14 KiB
Markdown
# 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. Фикс: токен/подпись запроса.
|
||
- [x] **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`
|