Files
pump-game-ops/docs/SECURITY.md
T
subochev b8a1f8dc39 docs(sec): SEC-9 закрыт — возврат залога при ничьей
Сводка: открыто 8 (было 9), закрыто 8. SEC-9 перенесён в закрытые с полной
историей: причина (close_bet.rs:66), замер доли ничьих (33.6%), решение
владельца (правка контракта), состав правок, собственная мутационная приёмка,
деплой и живой эффект (залипшие #0/#1 закрылись сами, ваулт −1000).
Хвост SEC-8 о «вечном залипании» помечен как устранённый.

Инструменты приёмки:
- tie-refund-accept.cjs — ставки циклом, проверка статуса на цепи
  (живой прогон: 6 возвратов из 25 ставок)
- tie-distribution.cjs, policy-economics.cjs — выбор политики ничьей
- vault-check.cjs — разбор Bet-аккаунтов сырыми байтами
2026-09-14 02:19:28 +03:00

90 lines
23 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]`, в закрытую строку добавлять дату и коммит/релиз.
**Сводка:** открыто 8 — критичных 1 (`SEC-5`, частично закрыт), экономика 1 (`SEC-7`),
инфраструктура 3 (`SEC-10…SEC-12`), отложено 3 (`SEC-13…SEC-15`). Закрыто: 8 —
`SEC-6` (2026-09-13, `updown-relayer:2` / `test-front:3`),
`SEC-1`, `SEC-2`, `SEC-4` (2026-09-13, `updown-relayer:3`, коммит `0b136c6`),
`SEC-3`, `SEC-8` (2026-09-13, `updown-relayer:6` — авто-закрытие + одна открытая ставка на адрес),
**`SEC-9`** (2026-09-14, `updown-relayer:7` / `test-front:5` — возврат залога при ничьей в контракте,
коммит `42dfa58`),
плюс `SEC-2` и `SEC-4` усилены в `:6`.
**Итерация 2026-09-13 (`updown-relayer:6`) — что сделано:** авто-закрытие ставок серверным
планировщиком (`settle.ts`), ручка `POST /close` удалена (`404`), WS-события `bet_open`/`bet_closed`
в существующем канале (`/ws`), ограничение «одна открытая ставка на адрес» (`409`),
пакетный обход цепочки вместо поштучного (9.3× быстрее).
*Проверено живьём:* 9/9 проверок сквозного сценария (кошелёк → faucet → ставка → блок второй
ставки → авто-закрытие через 16 с → `/close` 404).
---
## Критично — эксплуатируется
- [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:6`). Серверное авто-закрытие **сделано**: `relayer/src/settle.ts` — `startScheduler` крутит тик каждые `SMART_SETTLE_INTERVAL_MS` (дефолт 2000 мс), находит просроченные открытые ставки, берёт цену из истории на момент `expire_time` и сам зовёт `close_bet`. Клиент больше не участвует в закрытии вообще — ручки `/close` нет.
*Проверено на живом стенде:* ставка `id=14` закрыта **сама через 16 с** после экспирации, `exitPrice=10037000000`, `status=2` (`house_won`), по WS пришло событие `bet_closed`.
*Дополнительно:* в шлюзе включён пакетный обход (`getProgramAccounts`, 1 запрос вместо N) — замерено **3 мс против 28 мс** у поштучного перебора, 9.3× быстрее, и время тика больше не растёт с числом ставок.
- [x] **SEC-3. `POST /close` доступен из интернета без авторизации** — **ЗАКРЫТ 2026-09-13** (релиз `updown-relayer:6`). Ручка **удалена**: закрывать ставки снаружи больше нечем. Закрытие делает сам шлюз — серверный планировщик (`relayer/src/settle.ts`, `startScheduler`) каждые `SMART_SETTLE_INTERVAL_MS` (дефолт 2000 мс) обходит Bet-аккаунты, для просроченных (`expire_time*1000 <= now`) берёт цену из истории на момент экспирации и сам зовёт `close_bet`. Выгоды позвать чужую ставку в удобный момент больше нет — и звать нечего.
*Проверено на живом стенде:* `POST /close` → **404**; ставка `id=14` (UP, вход `10038000000`) закрыта **автоматически через 16 с** после экспирации, `exitPrice=10037000000`, `status=2` (`house_won`) — при цене вниз ставка UP проиграла, то есть расчёт корректен по направлению.
*Остаток (не критично):* авторизации на `/bet` по-прежнему нет (см. SEC-5).
- [x] **SEC-4. Безрисковый арбитраж на паре ставок UP+DOWN** — **ЗАКРЫТ 2026-09-13** (релиз `updown-relayer:6`, вторая линия обороны). Схема опиралась на возможность выбрать выгодный момент закрытия; цена закрытия фиксируется историей на момент `expire_time` (релиз `:3`), и **вдобавок пара больше физически не набирается**: шлюз отклоняет вторую ставку, пока открыта первая. Арбитраж закрыт дважды — и по цене, и по возможности занять обе стороны.
*Проверено на живом стенде:* на кошелёк с открытой ставкой `id=14` ставка DOWN → **409** `{"error":"you already have an open bet: close it before placing a new one","openBetId":"14"}`. Первая ставка при этом ставится нормально (`200`).
- [ ] **SEC-5. `POST /bet` без авторизации** — любой может создавать ставки на произвольные адреса. Проверено: `curl -X POST .../api/bet` → 200. **Частично закрыт 2026-09-13** (релиз `updown-relayer:6`): накрутка счётчика `global.counter` больше не работает в промышленных масштабах — на адрес допускается только **одна открытая** ставка, вторая → `409`. Ручка по-прежнему не авторизована, поэтому перебор адресов формально возможен. Фикс: токен/подпись запроса.
- [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.
- [x] **SEC-8. Нефинализированные ставки запирают средства в ваулте** — **ЗАКРЫТ 2026-09-13** (релиз `updown-relayer:6`). Причины было две, обе устранены. **(1) Закрывать было некому:** авто-закрытия не существовало, а клиент мог просто не позвать `/close` — теперь шлюз закрывает сам (см. SEC-2), и ставка не может остаться висеть. **(2) Можно было набрать много ставок разом:** ничто не мешало занять сто ставок, заперев сто залогов; теперь на адрес допускается **одна открытая** ставка (вторая → `409`), поэтому размер запертого одним человеком залога ограничен одной ставкой.
*Проверено на живом стенде:* вторая ставка при открытой первой → `409`; висевшая ставка `id=14` закрыта автоматически через 16 с, средства разблокированы.
*Остаток (закрыт):* если ставка попадала в «равные цены» (`exit == entry`), залог оставался запертым, а из-за правила «одна ставка на адрес» игрок терял возможность играть (`409`). **Устранено 2026-09-14 вместе с `SEC-9`** — ничья возвращает залог и финализирует ставку, поэтому залипаний больше нет.
- [x] **SEC-9. Равные `exit_price == entry_price` не финализировали ставку — ЗАКРЫТО 2026-09-14 (возврат залога в контракте).**
**Было:** `close_bet.rs:66` делал `return Ok(())`, статус оставался `open`; `settle.ts:303-304` берёт цену выхода по **фиксированному** моменту экспирации, поэтому ретрай каждые 2 с вечно получал ту же пару — залипание ВЕЧНОЕ. Замер: равные цены — **33.6 % окон** (11 682 из 34 769 за 580 минут). Наложенный эффект `SEC-8`: игрок с залипшей ставкой получал `409` навсегда.
**Решение владельца:** править контракт (залог лежит в ваулте под PDA — тронуть может только контракт, значит «настоящий возврат» возможен лишь правкой Rust). Программа обновляемая, authority `GXd83Q…` — у нас.
**Сделано:**
* `constants.rs`: `STATUS_REFUNDED = 3` (0/1/2 не тронуты — зашиты в шлюз и в живых аккаунтах цепи).
* `close_bet.rs`: при `exit == entry` — CPI-перевод `amount` из ваулта беттору под сидами ваулт-PDA, запись `exit_price` и `status = 3`, событие `BetClosed { user_won: false, payout: amount, status: 3 }`.
* `settle.ts`: `STATUS_REFUNDED`, `statusName → "refunded"`, статус 3 в условии финализации (иначе возврат попадал в `unexpected status` и WS-событие игроку не уходило).
* `check.ts`: проверка формы события `refunded`.
* Тест `close_bet_equal_price_stays_open` (закреплял старое поведение) → `close_bet_equal_price_refunds` + отклонение повторного закрытия.
* UI (`test-front/index.html`): три исхода вместо двух — ничья больше не показывается как «ПРОИГРАЛ (house_won)»; `API.md` дополнен.
**Проверка (своими руками, не отчётом агента):** `cargo test-sbf` 10/10 зелёных; `npm run typecheck` EXIT=0; две мутации (снять запись статуса / снять CPI-перевод) валят ровно новый тест, после отката снова 10/10.
**Деплой:** программа `9ALs…` апгрейднута (ProgramData расширен 247 848 → 267 848, хэш дампа из цепи совпал с локальным `.so` побайтово); шлюз `updown-relayer:7` (helm rev 15); фронт `test-front:5` (rev 5).
**Живой эффект:** две залипшие ставки (`#0`, `#1`) закрылись **сами** статусом 3; в обеих транзакциях ваулт −500 и игрок +500 фантиков (проверено `preTokenBalances`/`postTokenBalances`), ваулт 26 850 → 25 850. Канал `bet_closed` → `statusName: "refunded"` подтверждён.
*Остаток:* политика выбрана как «возврат залога без множителя»; доля ничьих ~33 % означает, что треть ставок не приносит дому ни прибыли, ни убытка (экономика остаётся положительной за счёт множителя 1.3×).
## Средний приоритет — инфраструктура
- [ ] **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`