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.
11 KiB
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 viafinish(), 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&Poolwith 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-102createssbc_submissions(id, profile_id, sbc_id, submitted_card_ids, passed, submitted_at)— noclub_id, and no later migration adds one. Butsrc/routes/club.rs:94-99(get_milestones) runsSELECT COUNT(*) FROM sbc_submissions WHERE club_id = ? AND passed = 1. SQLite errorsno such column: club_id; the error is swallowed by.unwrap_or(0)→ thesbcs_completedmilestone is always 0. Writerservices/sbc.rs:74-83insertsprofile_id, notclub_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 backfillUPDATE sbc_submissions SET club_id = (SELECT c.id FROM clubs c WHERE c.profile_id = sbc_submissions.profile_id);and bindclub_idinsbc.rs:submit_sbc's INSERT (club_idis already a param atsbc.rs:41). - Simpler alternative (no migration): change the
club.rs:95query toWHERE 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 asservices/economy.rs:214-236already does; route pack/market/sbc/checkin/ upgrades througheconomy.rs's composed atomic ops (or add&mut SqliteConnectionvariants ofspend_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-70iteratesreq.owned_card_idswith no dedup and no length bound. The sameowned_card_idrepeated N times resolves the same card N times (eachfetch_optionalsucceeds);validate_sbc(sbc.rs:110-116) counts it towardsquad_sizeand passes; the DELETE loop deletes it once → a user satisfies any SBC with ONE card duplicated → free rewards. Entry pointroutes/sbc.rs:30-49forwardsrequnvalidated. Unbounded Vec length is also a DoS. - Fix: in
submit_sbc, reject duplicate ids (collect into aHashSet, comparelen) and bound the list (e.g. ≤ 30) before resolving →AppError::BadRequest. - Already-good validation (no change):
economy.rs:64-67,84-87reject negative amounts;match_service.rs:100-104clamps goals 0..99;upgrades.rsvalidates boost 1..3 / positions. Minor:routes/economy.rs post_grant_rewardforwards an unboundedamount(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()onfetch_optionalOptions at:23(get_or_create),:69and:144(record_match) — panic if theseasonsrow is absent when expected.seasons.profile_idis 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,~88indexSTREAK_COINS[idx]withidx = ((streak-1)%7) as usize; Rust%can be negative → a corrupt/negative persistedstreak_dayyields 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 clampstreak_day >= 1on 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_affectedguard; 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_affectedguard before crediting. Kills double-credit via double-sell.checkin::claim— conditionalINSERT … SELECT … WHERE NOT EXISTS (today's row)rows_affectedguard; 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.