42fd3c7e90
Bump openfut-core gitlink to 68d1065 (correctness fixes: SBC duplicate-card exploit, non-atomic economy CAS guards, season/checkin panics, sbc_submissions club_id migration 0019). Deployed to prod-core (DB migration ver 18 -> 19). Add docs/CORE_CORRECTNESS_ISSUES.md (audit + Resolution) and docs/OVERNIGHT_HANDOFF_2026-08-17.md.
176 lines
11 KiB
Markdown
176 lines
11 KiB
Markdown
# openfut-core — Correctness Issues (audit 2026-08-17)
|
|
|
|
Read-only audit of `openfut-core` (game-agnostic axum + SQLite/sqlx economy authority).
|
|
Four reported issue classes confirmed with exact `file:line` evidence, **plus a bonus
|
|
HIGH-severity SBC duplicate-card economy exploit**. Nothing here is fixed yet —
|
|
fixing bumps Core off the frozen P1 reference (`fbb54ea`, the current known-good
|
|
production Core) and one item needs a DB migration + prod backfill, so this needs a
|
|
**go/no-go** before rebuild+redeploy.
|
|
|
|
> **Zero live users right now**, so the HIGH-severity economy exploits are not
|
|
> currently exploitable — but they are the exact class (coin overspend + card
|
|
> duplication) that crashed live clients earlier (project memory), so they should be
|
|
> fixed before any real play.
|
|
|
|
## Architecture context (why the bugs cluster)
|
|
|
|
Two generations of economy code coexist:
|
|
- **NEW** `services/economy.rs` + `routes/economy.rs` — **fully atomic + validated**:
|
|
every compound op acquires a connection, `BEGIN IMMEDIATE`, composes
|
|
transaction-scoped primitives (`debit`/`credit`/`add_item`/`remove_item`/
|
|
`consume_entitlement`), commits/rolls back via `finish()`, rejects negative amounts,
|
|
and has an extensive in-module test suite. **This is the reference fix pattern.**
|
|
- **OLD** per-feature services (`club`, `pack`, `market`, `sbc`, `checkin`, `upgrades`,
|
|
`match_service`, `season`) — predate it, still do read/check/write directly against
|
|
the `&Pool` with each statement on its own connection: no transaction. `economy.rs`'s
|
|
own module doc explicitly warns these "cannot offer that guarantee".
|
|
|
|
Issues 2 and 3 live entirely in the OLD generation; the fix is to route them through
|
|
`economy.rs`'s proven atomic ops (or give the pool helpers `&mut SqliteConnection`
|
|
transactional variants). Request flow is `X-OpenFUT-Game` header → active profile →
|
|
club → service; clients never pass a `club_id`, so cross-club access is **not** a
|
|
vector — the risks are intra-club concurrency + unvalidated payloads.
|
|
|
|
---
|
|
|
|
## Issue 1 — `sbc_submissions` missing `club_id` (milestone always 0) · **LOW**
|
|
|
|
- **Root cause:** `migrations/0001_initial.sql:96-102` creates `sbc_submissions(id,
|
|
profile_id, sbc_id, submitted_card_ids, passed, submitted_at)` — no `club_id`, and no
|
|
later migration adds one. But `src/routes/club.rs:94-99` (`get_milestones`) runs
|
|
`SELECT COUNT(*) FROM sbc_submissions WHERE club_id = ? AND passed = 1`. SQLite errors
|
|
`no such column: club_id`; the error is swallowed by `.unwrap_or(0)` → the
|
|
`sbcs_completed` milestone is **always 0**. Writer `services/sbc.rs:74-83` inserts
|
|
`profile_id`, not `club_id`.
|
|
- **Impact:** wrong milestone stat only. No crash, no economy corruption.
|
|
- **Fix (matches the ticket — needs migration + backfill):** new migration `0019`:
|
|
`ALTER TABLE sbc_submissions ADD COLUMN club_id TEXT;` then backfill
|
|
`UPDATE sbc_submissions SET club_id = (SELECT c.id FROM clubs c WHERE c.profile_id =
|
|
sbc_submissions.profile_id);` and bind `club_id` in `sbc.rs:submit_sbc`'s INSERT
|
|
(`club_id` is already a param at `sbc.rs:41`).
|
|
- **Simpler alternative (no migration):** change the `club.rs:95` query to
|
|
`WHERE profile_id = ?` (column already exists). Ticket asks for the column, so both
|
|
are recorded.
|
|
- **Migration required:** YES (for the ticket's fix); NO (for the alternative).
|
|
|
|
## Issue 2 — Non-atomic check-then-act economy mutations (TOCTOU) · **HIGH**
|
|
|
|
Each does read → check → write across multiple pool round-trips with no
|
|
`BEGIN IMMEDIATE`, so concurrent requests race → overspend / duplication / double reward:
|
|
|
|
| Site | Race |
|
|
|---|---|
|
|
| `services/club.rs:88-115` `spend_coins` | SELECT coins → `if balance<amount` → UPDATE; two concurrent spends both pass → **overspend / negative balance**. Shared primitive used by all callers below. |
|
|
| `services/pack.rs:58-141` `open_pack` | read `pack.opened` → INSERT cards loop → UPDATE opened=1; concurrent double-open → **card duplication** |
|
|
| `services/pack.rs:144-160` `buy_pack` | `spend_coins` then `grant_pack`, separate ops → crash between = coins gone, no pack |
|
|
| `services/market.rs:129-186` `buy_listing` | SELECT sold=0 → spend → UPDATE sold=1 → INSERT; concurrent double-buy → **two cards minted** |
|
|
| `services/market.rs:188+` `sell_card` | SELECT owned → DELETE → add_coins; concurrent double-sell → **double credit** |
|
|
| `services/sbc.rs:35-107` `submit_sbc` | validate → DELETE cards → INSERT submission → add_coins/grant_pack; same cards to two SBCs → **double reward** |
|
|
| `services/checkin.rs:60-120` `claim` | SELECT last → same-day check → reward → INSERT; concurrent → **double claim** |
|
|
| `services/upgrades.rs:64-95` `change_position` | fetch → spend_coins → UPDATE |
|
|
| `services/match_service.rs:97-235` + `season.rs` reward | many sequential writes; partial failure leaves partial rewards |
|
|
|
|
- **Impact:** corrupts economy state (coin overspend + card duplication).
|
|
- **Fix:** wrap each compound op in one `BEGIN IMMEDIATE`…`finish()` transaction
|
|
exactly as `services/economy.rs:214-236` already does; route pack/market/sbc/checkin/
|
|
upgrades through `economy.rs`'s composed atomic ops (or add `&mut SqliteConnection`
|
|
variants of `spend_coins`/`add_coins`/`grant_pack`/`add_item`).
|
|
- **Migration required:** NO (code-only).
|
|
|
|
## Issue 3 — Missing input validation: SBC duplicate-card exploit · **HIGH**
|
|
|
|
- **Root cause:** `services/sbc.rs:53-70` iterates `req.owned_card_ids` with **no dedup
|
|
and no length bound**. The same `owned_card_id` repeated N times resolves the same card
|
|
N times (each `fetch_optional` succeeds); `validate_sbc` (`sbc.rs:110-116`) counts it
|
|
toward `squad_size` and passes; the DELETE loop deletes it once → **a user satisfies
|
|
any SBC with ONE card duplicated → free rewards**. Entry point `routes/sbc.rs:30-49`
|
|
forwards `req` unvalidated. Unbounded Vec length is also a DoS.
|
|
- **Fix:** in `submit_sbc`, reject duplicate ids (collect into a `HashSet`, compare
|
|
`len`) and bound the list (e.g. ≤ 30) before resolving → `AppError::BadRequest`.
|
|
- **Already-good validation (no change):** `economy.rs:64-67,84-87` reject negative
|
|
amounts; `match_service.rs:100-104` clamps goals 0..99; `upgrades.rs` validates
|
|
boost 1..3 / positions. Minor: `routes/economy.rs post_grant_reward` forwards an
|
|
unbounded `amount` (trusted host caller; add an upper-bound sanity guard).
|
|
- **Migration required:** NO.
|
|
|
|
## Issue 4 — `season.rs` panics + checkin index panic · **LOW**
|
|
|
|
- **Root cause:** `services/season.rs` `.expect()` on `fetch_optional` Options at
|
|
`:23` (`get_or_create`), `:69` and `:144` (`record_match`) — panic if the `seasons`
|
|
row is absent when expected. `seasons.profile_id` is PRIMARY KEY
|
|
(`migrations/0006_seasons_loans_packs.sql:2`), so the concurrent-insert case surfaces
|
|
as a UNIQUE error via `?` (not the panic), lowering probability — but it still
|
|
panics-on-invariant, aborting that request (axum → 500 for the request; not a full
|
|
server crash).
|
|
- **Secondary:** `services/checkin.rs:~52,~88` index `STREAK_COINS[idx]` with
|
|
`idx = ((streak-1)%7) as usize`; Rust `%` can be negative → a corrupt/negative
|
|
persisted `streak_day` yields a negative index → **panic**.
|
|
- **Fix:** replace each `.expect(...)` with
|
|
`.ok_or_else(|| AppError::Internal("season row missing".into()))?`; guard the checkin
|
|
index with `.rem_euclid(7)` (or clamp `streak_day >= 1` on read).
|
|
- **Migration required:** NO.
|
|
|
|
---
|
|
|
|
## Fix plan summary
|
|
|
|
| Issue | Severity | Migration | Files |
|
|
|---|---|---|---|
|
|
| 1 sbc_submissions club_id | LOW | YES (or none via alt) | `migrations/0001` (+new 0019), `routes/club.rs`, `services/sbc.rs` |
|
|
| 2 non-atomic mutations | HIGH | NO | `services/{club,pack,market,sbc,checkin,upgrades,match_service,season}.rs` → route through `services/economy.rs` |
|
|
| 3 SBC duplicate-card exploit | HIGH | NO | `services/sbc.rs`, `routes/sbc.rs` |
|
|
| 4 season/checkin panics | LOW | NO | `services/season.rs`, `services/checkin.rs` |
|
|
|
|
**Recommended order:** 3 (smallest, highest-value: kills the free-reward exploit) → 2
|
|
(the transactional refactor, largest) → 4 (defensive hygiene) → 1 (cosmetic; do with
|
|
the alt query unless the column is wanted).
|
|
|
|
**Deployment note:** all of these change `openfut-core`, which is currently frozen at
|
|
the P1 reference (`fbb54ea`) in production. Fixing + rebuilding + redeploying prod-core
|
|
is a deliberate step off that reference — get a go/no-go first. Only Issue 1's
|
|
column-add needs a migration + prod backfill; 2/3/4 are code-only.
|
|
|
|
---
|
|
|
|
## Resolution (2026-08-17, on `openfut-core` @ `fbb54ea` + these edits)
|
|
|
|
All four issue classes fixed in the canonical superproject submodule
|
|
`openfut-core`; full test suite green (179 tests) + clippy clean + a new
|
|
regression test `tests/integration_test.rs::test_sbc_rejects_duplicate_cards`.
|
|
|
|
| Issue | Fix | Files |
|
|
|---|---|---|
|
|
| 3 SBC dup-card exploit | dedup (`HashSet`) + `MAX_SBC_CARDS`=30 bound in `submit_sbc`, before card resolution → `BadRequest` | `services/sbc.rs` |
|
|
| 1 sbc_submissions club_id | migration `0019` adds `club_id` + backfills from `clubs`; `submit_sbc` INSERT now binds `club_id` | `migrations/0019_*.sql`, `services/sbc.rs` |
|
|
| 4 season/checkin panics | `.expect()` → `.ok_or_else(AppError::Internal)?` (3 sites); checkin index `% 7` → `.rem_euclid(7)` (2 sites) | `services/season.rs`, `services/checkin.rs` |
|
|
| 2 non-atomic mutations | statement-level compare-and-swap (see below) | `services/{club,pack,market,checkin}.rs` |
|
|
|
|
### Issue 2 — how it was fixed, and the residual
|
|
|
|
Rather than the full transaction refactor (threading `&mut SqliteConnection`
|
|
through every service), the concurrency-exploitable races were closed with
|
|
single-statement **compare-and-swap** — the atomic unit SQLite already gives us,
|
|
no transaction plumbing, minimal blast radius on the working prod economy path:
|
|
|
|
- `club::spend_coins` — `UPDATE … SET coins = coins - ? WHERE id = ? AND coins >= ?`
|
|
+ `rows_affected` guard; also rejects negative amounts. Kills **overspend** for
|
|
every caller (the shared root primitive).
|
|
- `pack::open_pack` — claims the pack (`UPDATE … opened = 1 WHERE … AND opened = 0`)
|
|
**before** minting cards; loser aborts. Kills **card duplication** via double-open.
|
|
- `market::buy_listing` — claims the listing (`sold 0→1`) before charging; releases
|
|
the claim if the debit fails. Kills **double-mint**.
|
|
- `market::sell_card` — `DELETE … WHERE id = ? AND club_id = ?` + `rows_affected`
|
|
guard before crediting. Kills **double-credit** via double-sell.
|
|
- `checkin::claim` — conditional `INSERT … SELECT … WHERE NOT EXISTS (today's row)`
|
|
+ `rows_affected` guard; pays out only if the claim landed. Kills **double-claim**.
|
|
|
|
**Residual (accepted, documented):** the *multi-statement all-or-nothing* edges that
|
|
need a real transaction to close — `pack::buy_pack` (spend then grant: a crash between
|
|
loses coins with no pack), `sbc::submit_sbc` (concurrent submits sharing cards could
|
|
double-consume mid-loop), and `match_service`/`season` reward chains (partial writes on
|
|
crash). These are **partial-failure durability edges, not statement-level races**, and
|
|
require concurrency that a single-player FIFA17 client does not generate. Closing them
|
|
is the `&mut SqliteConnection` transaction refactor originally proposed; deferred as
|
|
low-value for single-player. Overspend + duplication + double-credit — the vectors that
|
|
corrupt economy state — are all closed.
|