Files
pump-game-ops/docs/SECURITY.md
T
subochev 6e7132e935 docs(security): SEC-1, SEC-2, SEC-4 закрыты по сути — цена закрытия из истории
Механизм: шлюз берёт цену закрытия из локальной истории на момент expire_time
ставки (priceHistory, node:sqlite, ретеншен 24ч), а не текущую. Момент клика
перестаёт влиять на исход — окно выбора времени закрыто.

Живая проверка (ставка №10, релиз updown-relayer:3):
  цена на момент экспирации 9977000000
  текущая цена в момент клика 9976000000
  exitPrice в транзакции 9977000000  -> взята история, не 'сейчас'
  статус payout_done

Также: tools/ — скрипты проверки (price-history-probe, exploit-decisive,
exploit-check, check-close-window.sh).

Остаток: в контракте верхней границы окна по-прежнему нет (второй слой, не критично);
серверное авто-закрытие — следующая итерация.
2026-09-13 14:47:21 +03:00

61 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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]`, в закрытую строку добавлять дату и коммит/релиз.
**Сводка:** открыто 12 — критичных 3 (`SEC-3`, `SEC-5`), экономика 3 (`SEC-7…SEC-9`),
инфраструктура 3 (`SEC-10…SEC-12`), отложено 3 (`SEC-13…SEC-15`). Закрыто: 4 —
`SEC-6` (2026-09-13, `updown-relayer:2` / `test-front:3`),
`SEC-1`, `SEC-2`, `SEC-4` (2026-09-13, `updown-relayer:3`, коммит `0b136c6`).
---
## Критично — эксплуатируется
- [x] **SEC-1. Нет верхней границы окна закрытия ставки** — **ЗАКРЫТ 2026-09-13 по сути** (релиз `updown-relayer:3`, коммит `0b136c6`). Контракт по-прежнему не ограничивает время закрытия, **но выбирать момент расчёта больше нельзя**: шлюз берёт цену закрытия из локальной истории на момент `expire_time` ставки (`history.priceAtOrBefore(expireTime*1000)`), а не текущую. Поздний клик теперь даёт ту же цену, что и своевременный. Место правки: `relayer/src/gateway.ts` (`handleClose`), `relayer/src/priceHistory.ts`.
*Проверено на живом стенде (ставка №10):* цена на момент экспирации `9977000000`, текущая цена в момент клика `9976000000` → `exitPrice=9977000000` (взята история), ставка финализирована `payout_done`. Уязвимый код взял бы `9976000000`.
*Остаток (не критично):* в контракте верхней границы так и нет — открытый вопрос «как долго позволять закрывать» остаётся косметикой, поскольку цена уже зафиксирована. Строгий фикс на цепочке (тот, что был предложен изначально) — по-прежнему желателен как второй слой.
- [x] **SEC-2. Нет авто-закрытия ставок на сервере** — **ЗАКРЫТ 2026-09-13 по сути** (релиз `updown-relayer:3`). Серверное авто-закрытие по-прежнему не сделано (следующая итерация, по решению владельца), **но риск снят**: исход ставки больше не зависит от того, кто и когда позвал `/close`, потому что цена закрытия определяется историей. Опоздание сервера перестало быть риском — дозакрытие даст ту же цену. Место: `relayer/src/gateway.ts` (`handleClose`).
*Остаток:* без авто-закрытия незакрытые ставки продолжают запирать средства в ваулте (см. SEC-8) — вопрос экономики, не честности расчёта.
- [ ] **SEC-3. `POST /close` доступен из интернета без авторизации** — любой может закрыть чужую ставку в выбранный им момент. Проверено: `curl -X POST https://testfront.binom.pw/api/close -d '{"id":"1"}'` → обработчик выполняется. Фикс: авторизация + серверное закрытие.
- [x] **SEC-4. Безрисковый арбитраж на паре ставок UP+DOWN** — **ЗАКРЫТ 2026-09-13 по сути** (релиз `updown-relayer:3`, коммит `0b136c6`). Схема опиралась на возможность выбрать выгодный момент закрытия; теперь цена закрытия фиксируется историей на момент `expire_time`, и обе половины пары закрываются по одной и той же цене независимо от времени клика. Арбитраж исчезает вместе с SEC-1. *Прямого прогона пары UP+DOWN не делали — закрытие выводится из закрытия SEC-1 (тот же механизм).*
- [ ] **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`