fix(security): bound the loop-shortcut iteration count (pre-existing remote DoS in #5672) - #5756
Conversation
There was a problem hiding this comment.
Code Review
This pull request implements a safety limit (CR 732.2a) to prevent remote Denial of Service (DoS) attacks via unbounded loop-shortcut declarations. It introduces a MAX_SHORTCUT_CYCLES cap of 1,000 in the engine, clamping the drive period and rejecting over-cap Fixed iteration counts before a proposal is built. Additionally, it adds payload-guard validation in server-core to bound the shortcut count and nested template vectors at the WebSocket transport layer, accompanied by comprehensive unit and integration tests. No review comments were provided, so there is no feedback to address.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
…-rs#5672) DeclareShortcut carried an unvalidated IterationCount::Fixed(u32): the server payload guard discarded count with .. and handle_declare_shortcut moved it into the proposal unchecked, so materialize_fixed_shortcut drove for i in 0..n — one GameState clone per cycle, up to ~4.3e9. A u32 encodes that in ~10 JSON bytes, sailing through the 8 KB WS frame cap. Engine-authoritative fix: a single MAX_SHORTCUT_CYCLES cap rejects an over-cap Fixed count into the existing fail-closed handback before the sole ShortcutProposal build site (covers WS, WASM, Tauri, local); the same const clamps shortcut_drive_period, bounding the templated UntilLethal drive on every caller. Wire defense-in-depth: bound the Fixed count and the nested template vecs at the payload guard; the false "count is a small enum" comment is corrected. IterationCount is now matched exhaustively at both bound sites so a future count variant build-breaks rather than silently regressing the cap. CR 732.2a (safety limit, no rules ceiling) / CR 800.4a (handback to living seat). Assisted-by: ClaudeCode:claude-opus-4.8
e8a857b to
9530bfd
Compare
Parse changes introduced by this PRBaseline pending for |
🤖 AI text below 🤖
Summary
Bounds the loop-shortcut iteration count — a pre-existing remote DoS merged in #5672.
GameAction::DeclareShortcutcarried an unvalidatedIterationCount::Fixed(u32): the server payload guard discardedcountwith..(its "countis a small enum — nothing unbounded" comment was false),handle_declare_shortcutmoved it into the proposal unchecked, andmaterialize_fixed_shortcutdrovefor i in 0..n— oneGameStateclone + beat drive per cycle, up to ~4.3e9. The fix is engine-authoritative (oneMAX_SHORTCUT_CYCLEScap enforced at the single proposal-build authority, so every transport — WS, WASM, Tauri, local — is covered) with a wire defense-in-depth belt on the multiplayer server.Predecessor: #5672 (this is a security follow-up to that merged PR).
Threat model (honest, three-way — the multiplayer server applies an 8 KB inbound WS frame cap,
crates/phase-server/src/main.rs:409/:1420, before deserialize;guard_game_action_payloadruns post-deserialize,client_message_wire_guard.rs:50)Fixed(u32::MAX)— the one catastrophic remote vector. Au32scalar-encodes up to ~4.3e9 cycles in ~10 JSON bytes, so it sails through the 8 KB frame cap and forces ~4.3e9GameStateclones. A byte cap cannot see it; only a count cap can. Closed by the engineMAX_SHORTCUT_CYCLESreject-to-handback + a wirebound_batch_countbelt.UntilLethaldrive period (shortcut_drive_period= the client template schedule length, drivingapply_until_lethal_shortcut'sfor i in 0..period). Structurally unbounded in the engine, but the 8 KB WS frame cap bounds a hostile schedule to a few hundred entries (~1–2 s stall) on the remote transport — not a million-cycle remote DoS. Closed at the source for all callers (incl. in-process WASM/Tauri/local that bypass the WS cap) by clamping the sharedshortcut_drive_periodhelper.Targetspin'sVec<TargetPin>, eachRoundRobin/Piecewiseschedule vec). Defense-in-depth only: the 8 KB WS cap already keeps a remote nested payload to a few hundred structs, and the guard runs post-deserialize, so the addedbound_lists bound downstream compute/clone work (not the transient serde allocation) for in-process callers past the frame cap.Single-authority: the cap is checked once, before the sole
ShortcutProposalbuild site; both confirmation paths and both materializers (materialize_fixed_shortcut,materialize_object_growth_shortcut) read the boundednand never re-check.Implementation method (required)
Method: /engine-implementer
CR references
CR 732.2a— a shortcut is "a loop that repeats a specified number of times"; the CR places no board-relative upper bound, soMAX_SHORTCUT_CYCLESis annotated as an implementation safety limit, not a rules limit.CR 800.4a— an over-cap count hands priority to the next living seat (living_priority_seat).CR 704.5a— referenced by the unchangedUntilLethalterminator.Verification
Required checks ran clean (worktree cargo — own
target/, no Tilt lock contention).Gate A output below is for the current committed head.
Final review-impl below is clean for the current committed head.
Both anchors cite existing analogous code at the same seam.
cargo fmt --all— cleancargo clippy -p engine -p server-core --all-targets --features engine/proptest -- -D warnings— exit 0, no diagnostics (rebased head)cargo nextest run -p engine -p server-core(loop_shortcut + guard suite) — 63/63 pass, 0 failed (rebased head; includes the 5 new tests + the co-resident sibling-fix(ai): score the CR 732.2a loop-shortcut offer + cover the winner-liveness conjunct (#5672 follow-ups) #5748 tests merged in by the rebase)Discriminating tests (5 new, each revert-probe MEASURED FAIL→PASS):
over_cap_fixed_count_hands_back_with_no_drive(engine integration) — asserts the DRIVE did not run (life(P1)==l0, noGameOver);waiting_foris a sanity check only (handback and cap-absent stop-short both land onPriority— vacuity trap documented in the test///). Revert-probe (delete the engine cap, measured pre-rebase on byte-identical code):assert_eq!(life(P1), l0)FAILSleft: 0, right: 19— the drain drove to lethal in 0.128 s (CrossLethalcommits+stops, sou32::MAXreverted does not hang). Re-confirmed on the rebased head by the passing discrimination pair on the same fixture:b3_materialize_stop_short(Fixed(3), under-cap → drives) vs this test (Fixed(u32::MAX), over-cap → no drive) — only the count relative to the cap differs.shortcut_drive_period_is_schedule_max(engine unit) —RoundRobin(MAX+5) ⇒ MAX_SHORTCUT_CYCLES. Revert-probe (drop.clamp): FAILS1005 ≠ 1000.rejects_over_cap_fixed_shortcut_count(server-core) —Fixed(u32::MAX)rejected. Revert-probe (restore..): returnsOk→ FAILS.accepts_realistic_fixed_shortcut_count(server-core) —Fixed(50)accepted (non-vacuous). Revert-probe (threshold→0): FAILS.rejects_over_cap_shortcut_schedule(server-core) — over-cap nestedRoundRobinrejected. Revert-probe (disable schedule bounds): FAILS.Stated residual: no end-to-end runtime drive test of the templated-
UntilLethalfor i in 0..periodpath — a real over-cap drive would be the DoS. Coverage rests on theshortcut_drive_periodclamp unit test + the structural fact that both consumers (validate_pins,apply_until_lethal_shortcut) are literallyfor i in 0..period, so a bounded period ⇒ a bounded drive by construction.Gate A
Gate A PASS head=e8a857bdf5eebf15ad49858564c176d683e40748 base=8a1dfd7041297b48f7d3dfea6095b06c730edfd2
Anchored on
crates/engine/src/game/engine.rs(handle_declare_shortcutpin-validation handback) — existing fail-closedreset_priority→WaitingFor::Priority { living_priority_seat }→Okidiom reused for the over-cap count handback.crates/server-core/src/game_action_payload_guard.rs(ChooseManaColor.count→bound_batch_count) — existingu32-count wire bound reused forDeclareShortcut.count.Final review-impl
Final review-impl PASS head=e8a857bdf5eebf15ad49858564c176d683e40748