Skip to content

fix: accepts unbounded nested payload - #2262

Merged
matthewevans merged 5 commits into
phase-rs:mainfrom
jonathanchang31:fix/accepts-unbounded-nested-payload
Jun 4, 2026
Merged

fix: accepts unbounded nested payload#2262
matthewevans merged 5 commits into
phase-rs:mainfrom
jonathanchang31:fix/accepts-unbounded-nested-payload

Conversation

@jonathanchang31

Copy link
Copy Markdown
Contributor

Summary

  • Hardened GameAction wire validation against oversized nested client-controlled payloads.
  • Added bounds for SubmitSideboard card names, debug generic counter names, and debug keyword AST payloads.
  • Added dispatch-level coverage proving oversized ClientMessage::Action payloads are rejected before handler/engine work.
  • Validated server-core tests, server-core/phase-server compilation, diff hygiene, and phase-server --help runtime smoke execution.

Related Issue

Closes: #1892

Change Type

  • Bug fix
  • Security hardening
  • Test coverage
  • New feature
  • Refactor
  • Documentation
  • UI change

Real Behavior Proof

Before this change, some nested GameAction payload fields were not fully bounded even though outer vectors were checked.

Now these payloads are rejected:

  • SubmitSideboard.main[0].name longer than MAX_CHOICE_LEN
  • Debug.ModifyCounters.counter_type.Generic longer than MAX_CHOICE_LEN
  • Debug.GrantKeyword.keyword serialized payload larger than MAX_DEBUG_AST_JSON_LEN
  • oversized ClientMessage::Action before dispatch to handler work

Validation commands passed:

cargo fmt --all
CARGO_TARGET_DIR=/tmp/phase-1892-target cargo test -p server-core --test game_action_payload_guard -- --nocapture
CARGO_TARGET_DIR=/tmp/phase-1892-target cargo test -p server-core dispatch_guard_rejects_oversized_game_action_before_handler_work -- --nocapture
CARGO_TARGET_DIR=/tmp/phase-1892-target cargo check -p server-core
CARGO_TARGET_DIR=/tmp/phase-1892-target cargo check -p phase-server
git diff --check
CARGO_TARGET_DIR=/tmp/phase-1892-target cargo run -p phase-server -- --help

Test result proof:

running 11 tests
test accepts_reasonably_sized_action_list ... ok
test rejects_oversized_category_choice_payload ... ok
test rejects_oversized_choice_string ... ok
test passes_scalar_only_action ... ok
test rejects_oversized_debug_counter_name ... ok
test rejects_oversized_debug_payload ... ok
test rejects_oversized_debug_keyword_ast_payload ... ok
test rejects_oversized_action_list ... ok
test rejects_oversized_nested_sideboard_card_name ... ok
test rejects_oversized_phyrexian_choice_payload ... ok
test rejects_oversized_mana_choice_payloads ... ok

test result: ok. 11 passed; 0 failed
test client_message_wire_guard::tests::dispatch_guard_rejects_oversized_game_action_before_handler_work ... ok

Checklist

  • Added bounds for previously unbounded nested action payloads
  • Added regression tests for nested oversized payloads
  • Added dispatch-level guard coverage for ClientMessage::Action
  • Ran formatter
  • Ran targeted server-core tests
  • Ran server-core compile check
  • Ran phase-server compile check
  • Ran diff whitespace check
  • Ran phase-server runtime smoke check
  • No new dependencies or environment installation required

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces size validation guards for nested payloads in client-supplied game actions, such as sideboard submissions and debug actions, to prevent large allocations and resource exhaustion. The review feedback identifies a resource exhaustion vulnerability in bound_serialized_json where full in-memory serialization can be replaced with a custom byte-limiting writer, a performance bottleneck in the sideboard validation loop due to eager string formatting, and the opportunity to clean up the unused guard_deck_card_count_payload helper.

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.

Comment on lines +60 to +70
fn bound_serialized_json<T: Serialize>(field: &str, value: &T) -> Result<(), String> {
let serialized = serde_json::to_string(value)
.map_err(|err| format!("{field} could not be serialized for size validation: {err}"))?;
if serialized.len() > MAX_DEBUG_AST_JSON_LEN {
return Err(format!(
"{field} is {} serialized bytes; at most {MAX_DEBUG_AST_JSON_LEN} allowed",
serialized.len()
));
}
Ok(())
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

Resource Exhaustion Vector in Security Guard

Using serde_json::to_string allocates the entire serialized JSON string in memory. If an adversary sends a deeply nested or extremely large AST payload, this will allocate a huge string and consume significant CPU/memory before the length check is even evaluated, defeating the purpose of the guard.

We can serialize directly to a custom std::io::Write that counts bytes and returns an error immediately when MAX_DEBUG_AST_JSON_LEN is exceeded, completely avoiding any memory allocation for the serialized string.

fn bound_serialized_json<T: Serialize>(field: &str, value: &T) -> Result<(), String> {
    struct LimitingWriter {
        written: usize,
        limit: usize,
    }
    impl std::io::Write for LimitingWriter {
        fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
            self.written += buf.len();
            if self.written > self.limit {
                return Err(std::io::Error::new(
                    std::io::ErrorKind::InvalidData,
                    "limit exceeded",
                ));
            }
            Ok(buf.len())
        }
        fn flush(&mut self) -> std::io::Result<()> {
            Ok(())
        }
    }

    let mut writer = LimitingWriter {
        written: 0,
        limit: MAX_DEBUG_AST_JSON_LEN,
    };
    serde_json::to_writer(&mut writer, value)
        .map_err(|err| format!("{field} size validation failed or exceeded limit: {err}"))
}

Comment on lines +257 to +262
for (index, card) in main.iter().enumerate() {
guard_deck_card_count_payload(&format!("SubmitSideboard.main[{index}]"), card)?;
}
for (index, card) in sideboard.iter().enumerate() {
guard_deck_card_count_payload(&format!("SubmitSideboard.sideboard[{index}]"), card)?;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Allocation Bottleneck in Validation Loop

Looping over main and sideboard cards and calling guard_deck_card_count_payload with a formatted string allocates a new String via format! on every single iteration in the happy path. Since MAX_ACTION_LIST_LEN is 10,000, a valid but large sideboard action could trigger up to 20,000 string allocations, creating a significant performance bottleneck and a potential CPU/memory amplification vector.

We should perform the length check directly on the card name and only allocate/format the error string on failure.

            for (index, card) in main.iter().enumerate() {
                if card.name.len() > MAX_CHOICE_LEN {
                    return Err(format!(
                        "SubmitSideboard.main[{index}].name is {} bytes; at most {MAX_CHOICE_LEN} allowed",
                        card.name.len()
                    ));
                }
            }
            for (index, card) in sideboard.iter().enumerate() {
                if card.name.len() > MAX_CHOICE_LEN {
                    return Err(format!(
                        "SubmitSideboard.sideboard[{index}].name is {} bytes; at most {MAX_CHOICE_LEN} allowed",
                        card.name.len()
                    ));
                }
            }

Comment on lines +90 to +92
fn guard_deck_card_count_payload(field: &str, card: &DeckCardCount) -> Result<(), String> {
bound_string(&format!("{field}.name"), &card.name)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Unused Helper Function

Since we are inlining the sideboard card name validation to avoid allocating strings in a loop, this helper function is no longer needed and can be removed to keep the codebase clean.

@matthewevans

Copy link
Copy Markdown
Member

🤖 Architecture Review (automated)

Verdict: ✅ Approve

Seam: PASS — Wire-size validation correctly lives in server-core at the transport boundary (guard_client_message_before_dispatchguard_game_action_payload), before clone-heavy engine reducers; zero game logic added, consistent with the existing draft_action_payload_guard pattern.
Idiomatic: PASS — Exhaustive match with an explicit no-op variant list forces compile-time classification of any future DebugAction/GameAction/CounterType variant; nested guards delegate to existing bound_string/bound_list building blocks; bound_serialized_json streams via a counting io::Write (no full-string allocation) with a checked_add overflow guard.
Value: Covers the whole class of client-supplied nested payloads (sideboard card names, debug counter Generic names, keyword AST), not a single message — and the exhaustive matches mean new variants can't silently slip past the guard.
Reconciled with existing reviews: Gemini raised three findings against an earlier revision; all are CONFIRMED-then-RESOLVED at head 48f3356:

  • (HIGH) Resource exhaustion via serde_json::to_string — RESOLVED. Head now uses serde_json::to_writer into a LimitingWriter that errors at MAX_DEBUG_AST_JSON_LEN, so no full serialized string is allocated. Improves on the suggestion with a checked_add overflow guard.
  • (HIGH) Per-iteration format! allocation in the sideboard loop — RESOLVED. Head inlines a direct card.name.len() > MAX_CHOICE_LEN check and only format!s the error on failure; no allocation in the happy path.
  • (MED) Unused guard_deck_card_count_payload helper — RESOLVED. Helper removed at head.

Findings

No blocking issues found.

Additional correctness checks (all pass):

  • guard_counter_type_payload's CounterType::Keyword(_) => {} no-op is correct: Keyword(KeywordKind) carries a payload-free C-style discriminant (per CR 122.1b doc comment), and ModifyPlayerCounters's PlayerCounterKind is likewise payload-free — only CounterType::Generic(String) carries unbounded data and it is bounded.
  • bound_serialized_json is correctly applied to GrantKeyword/RemoveKeyword because Keyword nests arbitrary AST (Enchant(TargetFilter), HexproofFrom(HexproofFilter)), which a flat string bound can't cover. serde_json::to_writer propagates the first Write error, so it short-circuits.
  • CreateCard.card_name and AddMana.mana remain guarded (pre-existing); no nested-payload DebugAction variant is left unbounded.
  • The new wire-guard test routes through guard_client_message_before_dispatch (the real dispatch entry), not the guard helper directly, so it exercises the production path.

@jonathanchang31

Copy link
Copy Markdown
Contributor Author

@matthewevans Could you plz review my PR?

@matthewevans

Copy link
Copy Markdown
Member

Pushed a maintainer follow-up in a46d908f23 after the architecture pass found two remaining nested client-controlled debug payloads:\n\n- bounded Debug.CreateToken.request.enter_with_counters counter names, including custom CounterType::Generic entries\n- bounded custom debug token keyword AST payloads with the same streaming JSON size guard used for GrantKeyword / RemoveKeyword\n- classified the current-main CounterType::Fade variant as payload-free in the exhaustive counter guard\n- added regression tests for the debug token counter-name and keyword-AST paths\n\nVerification run locally with the shared target dir:\n- cargo fmt --all\n- git diff --check\n- CARGO_TARGET_DIR=/Users/matt/dev/forge.rs-pr-target RUSTC_WRAPPER= cargo test -p server-core --test game_action_payload_guard -- --nocapture (13 passed)\n- CARGO_TARGET_DIR=/Users/matt/dev/forge.rs-pr-target RUSTC_WRAPPER= cargo test -p server-core dispatch_guard_rejects_oversized_game_action_before_handler_work -- --nocapture (1 passed)

@matthewevans matthewevans added bug Bug fix area:multiplayer Multiplayer/server/lobby (phase-server, server-core) rust Pull requests that update rust code labels Jun 4, 2026
@matthewevans

Copy link
Copy Markdown
Member

Also merged current main after #2261 landed so the branch is current for CI/queue evaluation. No additional server-core changes beyond the maintainer fix described above.

@matthewevans matthewevans left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved after maintainer architecture review and focused verification. Correct seam: server-core transport-boundary guard before native WebSocket dispatch/clone-heavy engine handling. Idiomatic at seam: exhaustive GameAction/DebugAction/CounterType matching with shared bounded string/list/streaming JSON helpers; no game logic added. Regression/perf: rejects oversized client-controlled lists/strings/keyword ASTs before reducer work; streaming JSON avoids allocating a full serialized payload for AST bounds. Tests discriminate the previously open gaps: reverting the debug token counter or keyword AST guards makes the new tests fail. Local verification: cargo fmt --all; git diff --check; server-core game_action_payload_guard integration test (13 passed); dispatch pre-handler guard test (1 passed).

@matthewevans
matthewevans enabled auto-merge June 4, 2026 21:43
@matthewevans
matthewevans added this pull request to the merge queue Jun 4, 2026
Merged via the queue into phase-rs:main with commit b490c79 Jun 4, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:multiplayer Multiplayer/server/lobby (phase-server, server-core) bug Bug fix rust Pull requests that update rust code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Game Action accepts unbounded nested payloads

2 participants