101a4b60a9
Найдено при приёмке боевого домена smart-updown.binom.pw (симптом «SOL не обновляется»). - SEC-16: при create_bet ренту PDA-аккаунта ставки (1 559 040 lamports = (96+32+128)*6960) платит БЕТТОР, а не шлюз; баланс шлюза-админа при этом не меняется (замер whose-fee.cjs). Аккаунт не закрывается никогда (в контракте 9ALs четыре инструкции, ни одной close = bettor) → рента теряется безвозвратно. В цепи 75 аккаунтов = 0.116928 SOL заперто, растёт линейно. Вердикт: на тестах терпимо (маскируется краном, 5 SOL = ~3207 раундов), перед mainnet — блокер. - TASKS.md: фича «если цена не изменилась — подождать ещё 15 с» (НЕ доделана), заметка про боевой домен на общем TS-шлюзе и лаг крана 8–13 с. - tools: доказательные скрипты (whose-fee, bet-pda-rent, faucet-latency, sol-balance-cycle, settle-math-test, backend-full-test) + README с питфоллами замера. - k8s/smart-updown-api-ingress.yaml: /api,/ws вынесены вне helm (релиз игры их сносил).
108 lines
27 KiB
Markdown
108 lines
27 KiB
Markdown
# SECURITY.md — чек-лист безопасности игрового контура
|
||
|
||
**Статус:** открыт, ведётся с 2026-09-13.
|
||
**Область:** `smart-updown-token/relayer` (шлюз), контракт `smart-updown` (program `9ALs…`),
|
||
шина NATS, `test-front`. Стенды: `testfront.binom.pw` и боевой домен `smart-updown.binom.pw`
|
||
(k3s ns `game`; с 2026-09-15 оба домена обслуживает **один** TS-шлюз).
|
||
|
||
Аудит проведён **на живом стенде**, не по чтению кода: воспроизведение эксплойта, проверка
|
||
ручек из внешнего интернета, проверка аутентификации шины. Фактические выводы приведены в строках.
|
||
|
||
Пункты нумеруются сквозным `SEC-N` (не меняется при добавлении/закрытии пунктов) — ссылаться на них
|
||
номером. Отмечать закрытое — `[ ]` → `[x]`, в закрытую строку добавлять дату и коммит/релиз.
|
||
|
||
**Сводка:** открыто 9 — критичных 1 (`SEC-5`, частично закрыт), экономика 2 (`SEC-7`, **`SEC-16`**),
|
||
инфраструктура 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-15:** `SEC-16` — безвозвратная утечка ренты PDA-аккаунта ставки с кошелька
|
||
игрока (найдено при приёмке боевого домена `smart-updown.binom.pw`).
|
||
|
||
**Итерация 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-16. Рента PDA-аккаунта ставки списывается с кошелька ИГРОКА и НЕ возвращается — безвозвратная утечка `1 559 040` lamports за каждую ставку.**
|
||
**Найдено 2026-09-15** при приёмке боевого домена `smart-updown.binom.pw` (симптом владельца: «SOL не обновляется / не растёт»).
|
||
**Механика.** При `create_bet` создаётся PDA-аккаунт ставки (`PDA([BET_SEED, id_le_u64])`, размер `96` байт + 8 дискриминатор), и ренту за него платит **беттор**, а не шлюз. Рента = `(96 + 32 + 128) × 6960` lamports = **1 559 040** — это не комиссия транзакции (та 5 000).
|
||
**Замер на цепи** (`pump-game-ops/tools/whose-fee.cjs`): SOL игрока `5 000 000 000 → 4 998 440 960` (−1 559 040) при **неизменном** балансе шлюза-админа `E7Hg…` (`4 997 858 09 320`). Подтверждено по каждой ставке (`tools/bet-pda-rent.cjs`, id 60–74): у каждой PDA ровно 1 559 040 lamports.
|
||
**Почему безвозвратно.** Аккаунт ставки **никогда не закрывается**. В контракте `9ALs…` ровно четыре инструкции (`lib.rs`): `initialize`, `create_bet`, `close_bet`, `update` — ни одна не делает `close = bettor` / `close_account` / `realloc` (проверено грепом по `program/programs/smart-updown/src`). Рента остаётся приколотой к мёртвому аккаунту навсегда.
|
||
**Масштаб.** В цепи уже 75 аккаунтов ставок = **0.116928 SOL** запертой ренты (`getProgramAccounts`, `dataSize:96`), и она растёт линейно с числом ставок. Замер `tools/faucet-latency.cjs`: та же дельта воспроизводится на каждом раунде независимо от исхода (WIN/LOSS/TIE). По цене SOL $150–200 это **$0.25–0.31 реальных денег за каждый раунд** — до mainnet.
|
||
**Почему это пока не видно.** Кран (`fundBettor`, `ops.ts:204`) догружает кошелёк до 5 SOL, как только баланс падает ниже 2 SOL — на localnet утечка маскируется, 5 SOL хватает на ~3 207 раундов. **На mainnet airdrop'а нет**, поэтому игрок увидит, что баланс тает сам по себе, без проигрышей.
|
||
**Вердикт:** для тестов — **терпимо** (деньги фальшивые, маскируется краном). **Перед выводом на mainnet — блокер**, т.к. это списание реальных средств игрока без его убытка.
|
||
**Фикс (правка контракта + редеплой, на выбор):**
|
||
1. в `close_bet` добавить `close = bettor` на аккаунт `bet` — рента вернётся беттору при финализации;
|
||
2. либо платить ренту с кошелька **шлюза** (`relayer`/`admin`), тогда игрок не теряет SOL вовсе;
|
||
3. либо отдельная `close_bet`-инструкция очистки для уже накопленных аккаунтов.
|
||
|
||
## Средний приоритет — инфраструктура
|
||
|
||
- [ ] **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`
|