feat(inputs): server-side input-validation seam (ValidateInputs)#1535
Conversation
Add a minimal, ECS-capable hook for game-side input validation, per the maintainer's suggestion on the input-validation RFC: a normal Bevy system that intercepts received `InputMessage`s between `MessageSystems::Receive` and the input-buffer apply. - `MessageReceiver::retain_messages` mutates/drops buffered messages in place (preserves per-message metadata — no drain-then-re-push). - `InputSystems::ValidateInputs` system set, ordered `Receive → ValidateInputs → ReceiveInputs`; empty by default. - `app.add_input_validator(system)` schedules a validator in that set. A validator is a plain system with full `SystemParam` access, so it can clamp/inspect/reject against arbitrary game state — the thing the closed in-system validator framework (cBournhonesque#1529) couldn't do. Example + test included.
|
Co-authored critical pass from me + OpenAI Codex, refreshed at #1535 Current read: the PR pair now matches the design we were converging on. #1535 is a policy-free validation seam; #1526 is the opt-in spoofed-target helper built on that seam. I do not see a remaining PR-level architectural blocker. What changed since the previous pass
Remaining findings[P3] #1531's design-record text is now stale in a couple of places. [P3] The #1526 audit/status comment appears stale relative to the current head. Verdict#1535 looks ready from an API-design standpoint: #1526 also looks right after the latest fixes: it highlights the spoofed-target security issue, stays opt-in, strips only unauthorized entity targets, keeps emptied keepalive messages, documents ordering for follow-up validators, and now covers the important branch behavior. Merge order should remain #1535 first, then #1526 rebased down to the helper/tests. Co-authored-by: OpenAI Codex codex@openai.com |
Audit: server-side input-validation seam (#1535)Verdict: [APPROVE] — re-run at Build / test / lint (re-run at
|
…dback Drop the compulsory `AuthorizeInputs` set + `authorize_input_targets` system. The maintainer doesn't want `ControlledBy` to be a required component for inputs, and enabling authorization by default broke existing input tests that don't set it. Ship cBournhonesque#1535 as the seam only: `retain_messages`, `InputSystems::ValidateInputs`, and `add_input_validator` — a no-behavior-change addition. Authorization is now demonstrated as a *user-space* `ValidateInputs` validator (test `test_user_validator_can_authorize_targets`), which also asserts the authorized input still lands (non-overblocking) — the coverage gap the audit flagged. Whether lightyear ships such a check by default stays with cBournhonesque#1526.
Per Codex's review on the input-validation seam: retain_messages only exposes &mut M, hiding ReceivedMessage's remote_tick / channel_kind / message_id. Add retain_received_messages so validators that need that metadata (rate limiting, tick-window / staleness, replay diagnostics, per-channel policy) aren't forced to drain-and-re-push. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…get defense) Reframes cBournhonesque#1526 as an *opt-in* helper on the cBournhonesque#1535 validation seam, per the maintainer's position that `ControlledBy` is an optional, non-authoritative helper — not a mandatory input component, and not enforced by default. `authorize_controlled_targets::<S>` is a public `ValidateInputs` system that drops every `InputTarget::Entity` the sender doesn't control (`ControlledByRemote`), host-client exempt, `PreSpawned` passed through. A game enables the spoofed-target defense with: app.add_input_validator(authorize_controlled_targets::<MySequence>); Test asserts the authorized input still lands (non-overblocking) and the spoofed target is dropped. Stacked on cBournhonesque#1535 (the seam + retain_messages it builds on). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Per Codex's review on cBournhonesque#1535: the variant passed `&mut ReceivedMessage<M>`, letting validators rewrite the wire metadata (remote_tick / channel_kind / message_id), not just the data. Switch to `FnMut(MessageMetadata, &mut M)` — metadata is a read-only Copy view, only the message data is mutable. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…get defense) Reframes cBournhonesque#1526 as an *opt-in* helper on the cBournhonesque#1535 validation seam, per the maintainer's position that `ControlledBy` is an optional, non-authoritative helper — not a mandatory input component, and not enforced by default. `authorize_controlled_targets::<S>` is a public `ValidateInputs` system that drops every `InputTarget::Entity` the sender doesn't control (`ControlledByRemote`), host-client exempt, `PreSpawned` passed through. A game enables the spoofed-target defense with: app.add_input_validator(authorize_controlled_targets::<MySequence>); Test asserts the authorized input still lands (non-overblocking) and the spoofed target is dropped. Stacked on cBournhonesque#1535 (the seam + retain_messages it builds on). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…get defense) Reframes cBournhonesque#1526 as an *opt-in* helper on the cBournhonesque#1535 validation seam, per the maintainer's position that `ControlledBy` is an optional, non-authoritative helper — not a mandatory input component, and not enforced by default. `authorize_controlled_targets::<S>` is a public `ValidateInputs` system that drops every `InputTarget::Entity` the sender doesn't control (`ControlledByRemote`), host-client exempt, `PreSpawned` passed through. A game enables the spoofed-target defense with: app.add_input_validator(authorize_controlled_targets::<MySequence>); Test asserts the authorized input still lands (non-overblocking) and the spoofed target is dropped. Stacked on cBournhonesque#1535 (the seam + retain_messages it builds on). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Ready for review/merge. This is the policy-free Suggested merge order: this first, then #1526 rebases down to just the CI: Doc + Lint green; the full Test job is running (the suite passes locally — 🤖 Co-authored with Claude Code (Claude Opus 4.8) |
…get defense) Reframes cBournhonesque#1526 as an *opt-in* helper on the cBournhonesque#1535 validation seam, per the maintainer's position that `ControlledBy` is an optional, non-authoritative helper — not a mandatory input component, and not enforced by default. `authorize_controlled_targets::<S>` is a public `ValidateInputs` system that drops every `InputTarget::Entity` the sender doesn't control (`ControlledByRemote`), host-client exempt, `PreSpawned` passed through. A game enables the spoofed-target defense with: app.add_input_validator(authorize_controlled_targets::<MySequence>); Test asserts the authorized input still lands (non-overblocking) and the spoofed target is dropped. Stacked on cBournhonesque#1535 (the seam + retain_messages it builds on). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Default build = upstream 0.26.4 behavior + always-on security/correctness; every opinionated behavior change becomes opt-in via existing per-subsystem config, with an enable_fork_extensions() preset. Categorizes each divergence (opt-in / already-opt-in / always-on) and phases the work. Decisions locked: late-attach fixes are opt-in (strict parity); input-auth ships as a fork-local ValidateInputs seam now, converging with upstream cBournhonesque#1535/cBournhonesque#1526 later. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…puts seam Phase 3 of the opt-in configurability plan. c1d00a9 enforced target authorization inline in receive_input_message, silently dropping InputTarget::Entity a sender didn't control. Upstream (cBournhonesque#1531/cBournhonesque#1535/cBournhonesque#1526) deliberately makes this opt-in since ControlledBy is not an authoritative flag. Mirror that shape: - MessageReceiver::retain_messages(|&mut M| -> bool) — port of cBournhonesque#1535's in-place buffer mutation (Vec::retain_mut). - InputSystems::ValidateInputs set, ordered between MessageSystems::Receive and ReceiveInputs; InputValidatorAppExt::add_input_validator registers systems into it (the general game-side input-validation seam). - authorize_controlled_targets::<S> — opt-in validator (the extracted retain), NOT registered by default. Enable with add_input_validator(...). - Remove the inline retain from receive_input_message. The end_tick lookahead bound stays ALWAYS-ON (security: protects InputBuffer::set_raw from OOM). Default is now upstream behavior (any target reaches the buffer). Validation: serial suite 100 passed / 3 failed (was 4) — host_server bei::test_rebroadcast fixed by dropping compulsory auth; the two spoof-rejection tests register the validator and pass; no regressions. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Summary
A minimal, ECS-capable seam for game-side input validation on the server, per the design discussed in #1531. A normal Bevy system, with full
SystemParamaccess, runs between message receipt and input buffering and may mutate or drop receivedInputMessages in place.No new resource and no rewrite of
receive_input_message— systems intercept the existingMessageReceiver<InputMessage<S>>(aVecof received messages). No behavior change by default: the seam is empty until a game registers a validator.What's here
MessageReceiver::retain_messages(|&mut M| -> bool)— mutate and/or drop buffered messages in place, before they're consumed. In-place preserves per-message metadata (remote_tick,channel_kind,message_id) — unlike drain-then-re-push.MessageReceiver::retain_received_messages(|MessageMetadata, &mut M| -> bool)— same, but the predicate also gets the per-message metadata (remote_tick/channel_kind/message_id) read-only (only the data is&mut), for rate-limit / tick-window / replay / per-channel validators.InputSystems::ValidateInputs— empty-by-default set, ordered afterMessageSystems::Receiveand beforeInputSystems::ReceiveInputs.app.add_input_validator(system)— schedules a plain system intoValidateInputs(full ECS access).Tests
test_input_validator_system_can_drop_messages— a validator with ECS access (Res) drops messages viaretain_messages; the input never reaches the serverActionState.test_user_validator_can_authorize_targets— a game-supplied validator implementsControlledBy-based target authorization (dropsInputTarget::Entitythe sender doesn't control). Client 0 forges a target on an uncontrolled entity: the authorized input still lands (non-overblocking) and the spoofed one is dropped. Demonstrates the maintainer's "users can implement authorization themselves inValidateInputs" point.test_validator_can_read_message_metadata— a validator reads a message'sremote_tickviaretain_received_messages(read-onlyMessageMetadata) and drops it; asserts the metadata was observable and the drop took effect.Notes
receive_input_messageis unchanged, so the rebroadcast and rollback-mismatch machinery is untouched.end_tickbound) is merged and stays inline. feat(inputs): opt-in authorize_controlled_targets helper (spoofed-target defense) #1526 (target authorization) remains the place to decide whether lightyear enforces target ownership by default.Box<dyn>chain) — this gives full ECS access.Discussion / rationale: #1531.
🤖 Generated with Claude Code
Co-authored with Claude Opus 4.8