From 5a3ec2c2fe57640bf31192fc31ca92b2decc3d91 Mon Sep 17 00:00:00 2001 From: Mika Mannermaa Date: Thu, 25 Jun 2026 18:06:41 +0700 Subject: [PATCH 1/5] feat(inputs): server-side input-validation seam (ValidateInputs) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 (#1529) couldn't do. Example + test included. --- crates/inputs/inputs/src/lib.rs | 3 +- crates/inputs/inputs/src/server.rs | 92 +++++++- .../tests/src/client_server/input/leafwing.rs | 204 ++++++++++++++++++ crates/transport/messages/src/receive.rs | 13 ++ 4 files changed, 310 insertions(+), 2 deletions(-) diff --git a/crates/inputs/inputs/src/lib.rs b/crates/inputs/inputs/src/lib.rs index d8c55695e..3e9858927 100644 --- a/crates/inputs/inputs/src/lib.rs +++ b/crates/inputs/inputs/src/lib.rs @@ -42,7 +42,8 @@ pub mod prelude { #[cfg(feature = "server")] pub mod server { pub use crate::server::{ - InputRebroadcaster, InputSystems, ServerInputConfig, ServerInputPlugin, + InputRebroadcaster, InputSystems, InputValidationAppExt, ServerInputConfig, + ServerInputPlugin, }; } } diff --git a/crates/inputs/inputs/src/server.rs b/crates/inputs/inputs/src/server.rs index af2023164..c0be711ea 100644 --- a/crates/inputs/inputs/src/server.rs +++ b/crates/inputs/inputs/src/server.rs @@ -13,6 +13,7 @@ use alloc::format; use bevy_app::{App, FixedPreUpdate, Plugin, PreUpdate}; use bevy_ecs::component::Component; use bevy_ecs::prelude::Has; +use bevy_ecs::relationship::RelationshipTarget; use bevy_ecs::{ entity::{Entity, MapEntities}, error::Result, @@ -35,6 +36,7 @@ use lightyear_link::prelude::{LinkOf, Server}; use lightyear_messages::plugin::MessageSystems; use lightyear_messages::prelude::MessageReceiver; use lightyear_messages::server::ServerMultiMessageSender; +use lightyear_replication::control::ControlledByRemote; use lightyear_replication::prelude::{PreSpawned, RoomId, Rooms}; use tracing::{debug, error, trace}; @@ -116,12 +118,47 @@ pub type InputSet = InputSystems; #[derive(SystemSet, Debug, Hash, PartialEq, Eq, Clone, Copy)] pub enum InputSystems { + /// lightyear-owned authorization: drop `InputTarget::Entity` entries the + /// sender is not authorized to control (`ControlledByRemote`). Runs after + /// `MessageSystems::Receive` and **before** [`Self::ValidateInputs`], so + /// user validation systems never see spoofed targets. + AuthorizeInputs, + /// Validate / sanitize received [`InputMessage`]s before they are buffered. + /// Runs after [`Self::AuthorizeInputs`] and before [`Self::ReceiveInputs`]. + /// Empty by default — add systems here (see [`InputValidationAppExt::add_input_validator`]) + /// that mutate or drop messages via [`MessageReceiver::retain_messages`]. + ValidateInputs, /// Receive the latest ActionDiffs from the client ReceiveInputs, /// Use the ActionDiff received from the client to update the `ActionState` UpdateActionState, } +/// App-builder helper to register a server-side input-validation system. +/// +/// The system runs in [`InputSystems::ValidateInputs`] — after messages are +/// received, before they are buffered — so it can mutate or drop them with full +/// ECS access (any `SystemParam`). It typically queries +/// `Query<&mut MessageReceiver>>` and calls +/// [`MessageReceiver::retain_messages`]. This is sugar for +/// `app.add_systems(PreUpdate, system.in_set(InputSystems::ValidateInputs))`. +pub trait InputValidationAppExt { + fn add_input_validator( + &mut self, + systems: impl IntoScheduleConfigs, + ) -> &mut Self; +} + +impl InputValidationAppExt for App { + fn add_input_validator( + &mut self, + systems: impl IntoScheduleConfigs, + ) -> &mut Self { + self.add_systems(PreUpdate, systems.in_set(InputSystems::ValidateInputs)); + self + } +} + /// Component that is used to customize how inputs will be rebroadcasted /// /// If absent, the inputs received on a given `ClientOf` entity will be rebroadcasted to all other clients @@ -169,7 +206,13 @@ impl Plugin for ServerInputPlugin { // - but host-server broadcasting their inputs only updates `state` app.configure_sets( PreUpdate, - (MessageSystems::Receive, InputSystems::ReceiveInputs).chain(), + ( + MessageSystems::Receive, + InputSystems::AuthorizeInputs, + InputSystems::ValidateInputs, + InputSystems::ReceiveInputs, + ) + .chain(), ); app.configure_sets(FixedPreUpdate, InputSystems::UpdateActionState); @@ -181,6 +224,10 @@ impl Plugin for ServerInputPlugin { ); // SYSTEMS + app.add_systems( + PreUpdate, + authorize_input_targets::.in_set(InputSystems::AuthorizeInputs), + ); app.add_systems( PreUpdate, receive_input_message::.in_set(InputSystems::ReceiveInputs), @@ -192,6 +239,49 @@ impl Plugin for ServerInputPlugin { } } +/// lightyear-owned authorization pass (runs in [`InputSystems::AuthorizeInputs`], +/// before user [`InputSystems::ValidateInputs`]). +/// +/// Drops every `InputTarget::Entity` the sending peer is not authorized to +/// control — i.e. not a member of its [`ControlledByRemote`]. A message left +/// with no targets is removed entirely. This runs the [`#1526`] target-spoof +/// defense *before* the validation seam, so user validation systems only ever +/// see authorized `(sender, target)` pairs and never have to reason about +/// spoofed targets. +/// +/// - Host-client inputs (`RemoteId::is_local`) are trusted in-process and +/// skipped. +/// - `InputTarget::PreSpawned` is identified by hash, not entity id, so it is +/// passed through here (out of scope for this gate). +/// +/// [`#1526`]: https://github.com/cBournhonesque/lightyear/pull/1526 +fn authorize_input_targets( + mut receivers: Query< + ( + &RemoteId, + Option<&ControlledByRemote>, + &mut MessageReceiver>, + ), + With, + >, +) { + for (client_id, controlled_by_remote, mut receiver) in receivers.iter_mut() { + // Host-client inputs are authored in-process; trust them. + if client_id.is_local() { + continue; + } + receiver.retain_messages(|message| { + message.inputs.retain(|data| match data.target { + InputTarget::Entity(entity) => controlled_by_remote + .is_some_and(|controlled| controlled.collection().contains(&entity)), + InputTarget::PreSpawned(_) => true, + }); + // Drop the whole message once all of its targets were unauthorized. + !message.inputs.is_empty() + }); + } +} + // TODO: why do we need the Server? we could just run this on any receiver. // (apart from rebroadcast inputs) diff --git a/crates/tests/src/client_server/input/leafwing.rs b/crates/tests/src/client_server/input/leafwing.rs index 9e1d488b7..d44da109f 100644 --- a/crates/tests/src/client_server/input/leafwing.rs +++ b/crates/tests/src/client_server/input/leafwing.rs @@ -562,3 +562,207 @@ fn test_input_message_with_huge_end_tick_does_not_allocate_unbounded_buffer() { `is_input_within_lookahead` in lightyear_inputs::server.", ); } + +/// Example + test for the game-side input-validation seam: a normal Bevy system +/// registered with `add_input_validator` runs in `InputSystems::ValidateInputs` +/// (after receive, before buffering) with **full ECS access**, and mutates/drops +/// received `InputMessage`s in place via `MessageReceiver::retain_messages`. +/// +/// Here the validator reads a `Res` (proving arbitrary `SystemParam` +/// access) and drops every input message while the flag is set, so a legitimate, +/// authorized key press never reaches the server's `ActionState`. +#[test] +fn test_input_validator_system_can_drop_messages() { + use bevy::ecs::resource::Resource; + use bevy::ecs::system::{Query, Res}; + use lightyear::input::leafwing::input_message::LeafwingSequence; + use lightyear_inputs::input_message::InputMessage; + use lightyear_inputs::prelude::server::InputValidationAppExt; + use lightyear_messages::prelude::MessageReceiver; + + #[derive(Resource)] + struct RejectInputs(bool); + + // A game-side validation system: full ECS access (reads a resource), drops + // the input messages in place. A real validator would clamp/inspect against + // game state rather than reject wholesale. + fn reject_inputs( + reject: Res, + mut receivers: Query<&mut MessageReceiver>>>, + ) { + if !reject.0 { + return; + } + for mut receiver in &mut receivers { + receiver.retain_messages(|_msg| false); + } + } + + let mut stepper = ClientServerStepper::from_config(StepperConfig::with_netcode_clients(1)); + stepper.server_app.insert_resource(RejectInputs(true)); + stepper.server_app.add_input_validator(reject_inputs); + + let server_entity = stepper + .server_app + .world_mut() + .spawn(( + ActionState::::default(), + Replicate::to_clients(NetworkTarget::All), + )) + .id(); + stepper.frame_step(2); + + let local = stepper + .client(0) + .get::() + .unwrap() + .entity_mapper + .get_local(server_entity) + .expect("entity replicated to client 0"); + stepper.client_apps[0] + .world_mut() + .entity_mut(local) + .insert(InputMap::::new([( + LeafwingInput1::Jump, + KeyCode::KeyA, + )])); + stepper.frame_step(1); + stepper.client_apps[0] + .world_mut() + .resource_mut::>() + .press(KeyCode::KeyA); + stepper.frame_step(10); + + let server_state = stepper + .server_app + .world() + .entity(server_entity) + .get::>() + .expect("entity has ActionState"); + assert!( + !server_state.pressed(&LeafwingInput1::Jump), + "input reached the server even though the validation system dropped \ + every message in ValidateInputs — the seam isn't running before \ + ReceiveInputs, or retain_messages didn't take effect.", + ); +} + +/// The lightyear-owned `AuthorizeInputs` pass runs *before* the user +/// `ValidateInputs` seam, so a validation system never sees spoofed targets. +/// +/// Client 0 controls entity A and forges an input also targeting entity B +/// (which it does not control). A validation system records every target it +/// observes; it must see A but never B — B's target was stripped by +/// `authorize_input_targets` before the seam ran. B's server `ActionState` +/// must also be unaffected. +#[test] +fn test_authorization_runs_before_validation() { + use bevy::ecs::resource::Resource; + use bevy::ecs::system::{Query, ResMut}; + use lightyear::input::leafwing::input_message::LeafwingSequence; + use lightyear_inputs::input_message::{InputMessage, InputTarget}; + use lightyear_inputs::prelude::server::InputValidationAppExt; + use lightyear_messages::prelude::MessageReceiver; + use lightyear_replication::prelude::ControlledBy; + + #[derive(Resource, Default)] + struct ObservedTargets(Vec); + + // A validation system: records (without dropping) every entity target it + // sees in the received messages. Reads non-destructively via retain_messages + // returning `true`. + fn observe_targets( + mut observed: ResMut, + mut receivers: Query<&mut MessageReceiver>>>, + ) { + for mut receiver in &mut receivers { + receiver.retain_messages(|msg| { + for data in &msg.inputs { + if let InputTarget::Entity(e) = data.target { + observed.0.push(e); + } + } + true + }); + } + } + + let mut stepper = ClientServerStepper::from_config(StepperConfig::with_netcode_clients(1)); + stepper.server_app.init_resource::(); + stepper.server_app.add_input_validator(observe_targets); + + let client_of_0 = stepper.client_of(0).id(); + // Entity A: controlled by client 0. + let entity_a = stepper + .server_app + .world_mut() + .spawn(( + ActionState::::default(), + Replicate::to_clients(NetworkTarget::All), + ControlledBy { + owner: client_of_0, + lifetime: Default::default(), + }, + )) + .id(); + // Entity B: replicated to client 0 but NOT controlled by it (the spoof victim). + let entity_b = stepper + .server_app + .world_mut() + .spawn(( + ActionState::::default(), + Replicate::to_clients(NetworkTarget::All), + )) + .id(); + stepper.frame_step(10); + + let local_a = stepper + .client(0) + .get::() + .unwrap() + .entity_mapper + .get_local(entity_a) + .expect("A replicated to client 0"); + let local_b = stepper + .client(0) + .get::() + .unwrap() + .entity_mapper + .get_local(entity_b) + .expect("B replicated to client 0"); + + // Client 0 puts an InputMap on BOTH its own entity and the victim's, so its + // outgoing message targets A (legit) and B (spoofed). + for local in [local_a, local_b] { + stepper.client_apps[0].world_mut().entity_mut(local).insert( + InputMap::::new([(LeafwingInput1::Jump, KeyCode::KeyA)]), + ); + } + stepper.frame_step(1); + stepper.client_apps[0] + .world_mut() + .resource_mut::>() + .press(KeyCode::KeyA); + stepper.frame_step(10); + + let observed = &stepper.server_app.world().resource::().0; + assert!( + observed.contains(&entity_a), + "the validation system never saw the authorized target A — setup issue", + ); + assert!( + !observed.contains(&entity_b), + "the validation system saw spoofed target B; AuthorizeInputs must strip \ + unauthorized targets before ValidateInputs runs", + ); + assert!( + !stepper + .server_app + .world() + .entity(entity_b) + .get::>() + .unwrap() + .pressed(&LeafwingInput1::Jump), + "spoofed input landed on victim B's ActionState", + ); +} diff --git a/crates/transport/messages/src/receive.rs b/crates/transport/messages/src/receive.rs index 57c6e3047..c7f87403d 100644 --- a/crates/transport/messages/src/receive.rs +++ b/crates/transport/messages/src/receive.rs @@ -90,6 +90,19 @@ impl MessageReceiver { self.recv.drain(..) } + /// Mutate and/or drop the buffered messages in place, *before* they are + /// consumed by the receiving system. + /// + /// This is the hook for validation/sanitization systems that run between + /// message receipt and whatever consumes the messages (e.g. server-side + /// input validation between `MessageSystems::Receive` and the input-buffer + /// apply). Returning `false` from `keep` drops that message; mutating the + /// `&mut M` rewrites it. Per-message metadata (remote tick, channel, + /// message id) is preserved automatically — unlike drain-then-re-push. + pub fn retain_messages(&mut self, mut keep: impl FnMut(&mut M) -> bool) { + self.recv.retain_mut(|received| keep(&mut received.data)); + } + pub fn num_messages(&self) -> usize { self.recv.len() } From a716a7e527f59bf8151c2a1c6a5c743b09b08811 Mon Sep 17 00:00:00 2001 From: Mika Mannermaa Date: Thu, 25 Jun 2026 23:13:35 +0700 Subject: [PATCH 2/5] refactor(inputs): decouple seam from authorization per maintainer feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 #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 #1526. --- crates/inputs/inputs/src/server.rs | 65 ++--------------- .../tests/src/client_server/input/leafwing.rs | 73 ++++++++++--------- 2 files changed, 45 insertions(+), 93 deletions(-) diff --git a/crates/inputs/inputs/src/server.rs b/crates/inputs/inputs/src/server.rs index c0be711ea..f690a5005 100644 --- a/crates/inputs/inputs/src/server.rs +++ b/crates/inputs/inputs/src/server.rs @@ -13,7 +13,6 @@ use alloc::format; use bevy_app::{App, FixedPreUpdate, Plugin, PreUpdate}; use bevy_ecs::component::Component; use bevy_ecs::prelude::Has; -use bevy_ecs::relationship::RelationshipTarget; use bevy_ecs::{ entity::{Entity, MapEntities}, error::Result, @@ -36,7 +35,6 @@ use lightyear_link::prelude::{LinkOf, Server}; use lightyear_messages::plugin::MessageSystems; use lightyear_messages::prelude::MessageReceiver; use lightyear_messages::server::ServerMultiMessageSender; -use lightyear_replication::control::ControlledByRemote; use lightyear_replication::prelude::{PreSpawned, RoomId, Rooms}; use tracing::{debug, error, trace}; @@ -118,15 +116,12 @@ pub type InputSet = InputSystems; #[derive(SystemSet, Debug, Hash, PartialEq, Eq, Clone, Copy)] pub enum InputSystems { - /// lightyear-owned authorization: drop `InputTarget::Entity` entries the - /// sender is not authorized to control (`ControlledByRemote`). Runs after - /// `MessageSystems::Receive` and **before** [`Self::ValidateInputs`], so - /// user validation systems never see spoofed targets. - AuthorizeInputs, - /// Validate / sanitize received [`InputMessage`]s before they are buffered. - /// Runs after [`Self::AuthorizeInputs`] and before [`Self::ReceiveInputs`]. - /// Empty by default — add systems here (see [`InputValidationAppExt::add_input_validator`]) - /// that mutate or drop messages via [`MessageReceiver::retain_messages`]. + /// Validate / sanitize received [`InputMessage`]s before they are applied to + /// the [`InputBuffer`]. Runs after `MessageSystems::Receive` and before + /// [`Self::ReceiveInputs`]. Empty by default — add systems here (see + /// [`InputValidationAppExt::add_input_validator`]) that mutate or drop + /// messages via [`MessageReceiver::retain_messages`]. A game that wants to + /// authorize input targets against `ControlledBy` can do so here. ValidateInputs, /// Receive the latest ActionDiffs from the client ReceiveInputs, @@ -208,7 +203,6 @@ impl Plugin for ServerInputPlugin { PreUpdate, ( MessageSystems::Receive, - InputSystems::AuthorizeInputs, InputSystems::ValidateInputs, InputSystems::ReceiveInputs, ) @@ -224,10 +218,6 @@ impl Plugin for ServerInputPlugin { ); // SYSTEMS - app.add_systems( - PreUpdate, - authorize_input_targets::.in_set(InputSystems::AuthorizeInputs), - ); app.add_systems( PreUpdate, receive_input_message::.in_set(InputSystems::ReceiveInputs), @@ -239,49 +229,6 @@ impl Plugin for ServerInputPlugin { } } -/// lightyear-owned authorization pass (runs in [`InputSystems::AuthorizeInputs`], -/// before user [`InputSystems::ValidateInputs`]). -/// -/// Drops every `InputTarget::Entity` the sending peer is not authorized to -/// control — i.e. not a member of its [`ControlledByRemote`]. A message left -/// with no targets is removed entirely. This runs the [`#1526`] target-spoof -/// defense *before* the validation seam, so user validation systems only ever -/// see authorized `(sender, target)` pairs and never have to reason about -/// spoofed targets. -/// -/// - Host-client inputs (`RemoteId::is_local`) are trusted in-process and -/// skipped. -/// - `InputTarget::PreSpawned` is identified by hash, not entity id, so it is -/// passed through here (out of scope for this gate). -/// -/// [`#1526`]: https://github.com/cBournhonesque/lightyear/pull/1526 -fn authorize_input_targets( - mut receivers: Query< - ( - &RemoteId, - Option<&ControlledByRemote>, - &mut MessageReceiver>, - ), - With, - >, -) { - for (client_id, controlled_by_remote, mut receiver) in receivers.iter_mut() { - // Host-client inputs are authored in-process; trust them. - if client_id.is_local() { - continue; - } - receiver.retain_messages(|message| { - message.inputs.retain(|data| match data.target { - InputTarget::Entity(entity) => controlled_by_remote - .is_some_and(|controlled| controlled.collection().contains(&entity)), - InputTarget::PreSpawned(_) => true, - }); - // Drop the whole message once all of its targets were unauthorized. - !message.inputs.is_empty() - }); - } -} - // TODO: why do we need the Server? we could just run this on any receiver. // (apart from rebroadcast inputs) diff --git a/crates/tests/src/client_server/input/leafwing.rs b/crates/tests/src/client_server/input/leafwing.rs index d44da109f..dbcb65aac 100644 --- a/crates/tests/src/client_server/input/leafwing.rs +++ b/crates/tests/src/client_server/input/leafwing.rs @@ -647,49 +647,52 @@ fn test_input_validator_system_can_drop_messages() { ); } -/// The lightyear-owned `AuthorizeInputs` pass runs *before* the user -/// `ValidateInputs` seam, so a validation system never sees spoofed targets. +/// Example: a *game-supplied* `ValidateInputs` system implements input-target +/// authorization itself — lightyear does not enforce `ControlledBy` (it's an +/// optional helper). The validator drops any `InputTarget::Entity` the sender +/// doesn't control (via `ControlledByRemote` + `retain_messages`). /// /// Client 0 controls entity A and forges an input also targeting entity B -/// (which it does not control). A validation system records every target it -/// observes; it must see A but never B — B's target was stripped by -/// `authorize_input_targets` before the seam ran. B's server `ActionState` -/// must also be unaffected. +/// (uncontrolled). The validator must let A's input through (non-overblocking) +/// and drop B's — so A's server `ActionState` is pressed and B's is not. #[test] -fn test_authorization_runs_before_validation() { - use bevy::ecs::resource::Resource; - use bevy::ecs::system::{Query, ResMut}; +fn test_user_validator_can_authorize_targets() { + use bevy::ecs::relationship::RelationshipTarget; + use bevy::ecs::system::Query; use lightyear::input::leafwing::input_message::LeafwingSequence; + use lightyear_core::id::RemoteId; use lightyear_inputs::input_message::{InputMessage, InputTarget}; use lightyear_inputs::prelude::server::InputValidationAppExt; use lightyear_messages::prelude::MessageReceiver; + use lightyear_replication::control::ControlledByRemote; use lightyear_replication::prelude::ControlledBy; - #[derive(Resource, Default)] - struct ObservedTargets(Vec); - - // A validation system: records (without dropping) every entity target it - // sees in the received messages. Reads non-destructively via retain_messages - // returning `true`. - fn observe_targets( - mut observed: ResMut, - mut receivers: Query<&mut MessageReceiver>>>, + // Game-side authorization, expressed as an ordinary ValidateInputs system. + fn authorize_targets( + mut receivers: Query<( + &RemoteId, + Option<&ControlledByRemote>, + &mut MessageReceiver>>, + )>, ) { - for mut receiver in &mut receivers { + for (client_id, controlled, mut receiver) in &mut receivers { + if client_id.is_local() { + continue; + } receiver.retain_messages(|msg| { - for data in &msg.inputs { - if let InputTarget::Entity(e) = data.target { - observed.0.push(e); + msg.inputs.retain(|data| match data.target { + InputTarget::Entity(e) => { + controlled.is_some_and(|c| c.collection().contains(&e)) } - } - true + InputTarget::PreSpawned(_) => true, + }); + !msg.inputs.is_empty() }); } } let mut stepper = ClientServerStepper::from_config(StepperConfig::with_netcode_clients(1)); - stepper.server_app.init_resource::(); - stepper.server_app.add_input_validator(observe_targets); + stepper.server_app.add_input_validator(authorize_targets); let client_of_0 = stepper.client_of(0).id(); // Entity A: controlled by client 0. @@ -745,16 +748,18 @@ fn test_authorization_runs_before_validation() { .press(KeyCode::KeyA); stepper.frame_step(10); - let observed = &stepper.server_app.world().resource::().0; - assert!( - observed.contains(&entity_a), - "the validation system never saw the authorized target A — setup issue", - ); + // Non-overblocking: A's authorized input reached the server. assert!( - !observed.contains(&entity_b), - "the validation system saw spoofed target B; AuthorizeInputs must strip \ - unauthorized targets before ValidateInputs runs", + stepper + .server_app + .world() + .entity(entity_a) + .get::>() + .unwrap() + .pressed(&LeafwingInput1::Jump), + "the authorized input for A did not land — the validator over-stripped", ); + // The spoofed input for B was dropped by the validator. assert!( !stepper .server_app From e4ba614466debd3e39f357814b9e153d6dfc7ebb Mon Sep 17 00:00:00 2001 From: Mika Mannermaa Date: Thu, 25 Jun 2026 23:36:56 +0700 Subject: [PATCH 3/5] feat(messages): add retain_received_messages (metadata-exposing variant) 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 --- .../tests/src/client_server/input/leafwing.rs | 86 +++++++++++++++++++ crates/transport/messages/src/receive.rs | 15 ++++ 2 files changed, 101 insertions(+) diff --git a/crates/tests/src/client_server/input/leafwing.rs b/crates/tests/src/client_server/input/leafwing.rs index dbcb65aac..d991e2534 100644 --- a/crates/tests/src/client_server/input/leafwing.rs +++ b/crates/tests/src/client_server/input/leafwing.rs @@ -771,3 +771,89 @@ fn test_user_validator_can_authorize_targets() { "spoofed input landed on victim B's ActionState", ); } + +/// `retain_received_messages` exposes per-message metadata (`remote_tick`, +/// `channel_kind`, `message_id`) that `retain_messages` hides — needed for +/// rate-limit / tick-window / replay validators. Here a validator reads +/// `remote_tick` and drops the message; we assert both that the metadata was +/// readable and that the drop took effect. +#[test] +fn test_validator_can_read_message_metadata() { + use bevy::ecs::resource::Resource; + use bevy::ecs::system::{Query, ResMut}; + use lightyear::input::leafwing::input_message::LeafwingSequence; + use lightyear_inputs::input_message::InputMessage; + use lightyear_inputs::prelude::server::InputValidationAppExt; + use lightyear_messages::prelude::MessageReceiver; + + #[derive(Resource, Default)] + struct SeenRemoteTick(Option); + + fn inspect_metadata( + mut seen: ResMut, + mut receivers: Query<&mut MessageReceiver>>>, + ) { + for mut receiver in &mut receivers { + receiver.retain_received_messages(|received| { + // Metadata is reachable here (unlike `retain_messages`). + seen.0 = Some(received.remote_tick.0); + false // drop the message + }); + } + } + + let mut stepper = ClientServerStepper::from_config(StepperConfig::with_netcode_clients(1)); + stepper.server_app.init_resource::(); + stepper.server_app.add_input_validator(inspect_metadata); + + let server_entity = stepper + .server_app + .world_mut() + .spawn(( + ActionState::::default(), + Replicate::to_clients(NetworkTarget::All), + )) + .id(); + stepper.frame_step(2); + + let local = stepper + .client(0) + .get::() + .unwrap() + .entity_mapper + .get_local(server_entity) + .expect("entity replicated to client 0"); + stepper.client_apps[0] + .world_mut() + .entity_mut(local) + .insert(InputMap::::new([( + LeafwingInput1::Jump, + KeyCode::KeyA, + )])); + stepper.frame_step(1); + stepper.client_apps[0] + .world_mut() + .resource_mut::>() + .press(KeyCode::KeyA); + stepper.frame_step(10); + + assert!( + stepper + .server_app + .world() + .resource::() + .0 + .is_some(), + "validator never observed a message's remote_tick metadata", + ); + assert!( + !stepper + .server_app + .world() + .entity(server_entity) + .get::>() + .unwrap() + .pressed(&LeafwingInput1::Jump), + "input landed even though retain_received_messages dropped the message", + ); +} diff --git a/crates/transport/messages/src/receive.rs b/crates/transport/messages/src/receive.rs index c7f87403d..a55864cb5 100644 --- a/crates/transport/messages/src/receive.rs +++ b/crates/transport/messages/src/receive.rs @@ -103,6 +103,21 @@ impl MessageReceiver { self.recv.retain_mut(|received| keep(&mut received.data)); } + /// Like [`retain_messages`](Self::retain_messages), but the predicate also + /// gets the per-message metadata on [`ReceivedMessage`] — `remote_tick`, + /// `channel_kind`, `message_id` — which `retain_messages` hides. + /// + /// Use this when validation needs the metadata, e.g. rate limiting, + /// tick-window / staleness checks, replay diagnostics, or per-channel + /// policy. Mutate `received.data` to rewrite the message; return `false` to + /// drop it. + pub fn retain_received_messages( + &mut self, + mut keep: impl FnMut(&mut ReceivedMessage) -> bool, + ) { + self.recv.retain_mut(|received| keep(received)); + } + pub fn num_messages(&self) -> usize { self.recv.len() } From a4237230ab516d223b79623436185e3447c2706b Mon Sep 17 00:00:00 2001 From: Mika Mannermaa Date: Fri, 26 Jun 2026 00:11:31 +0700 Subject: [PATCH 4/5] refactor(messages): retain_received_messages exposes read-only metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per Codex's review on #1535: the variant passed `&mut ReceivedMessage`, 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 --- .../tests/src/client_server/input/leafwing.rs | 6 ++-- crates/transport/messages/src/receive.rs | 33 +++++++++++++++---- 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/crates/tests/src/client_server/input/leafwing.rs b/crates/tests/src/client_server/input/leafwing.rs index d991e2534..b0e5c4a8c 100644 --- a/crates/tests/src/client_server/input/leafwing.rs +++ b/crates/tests/src/client_server/input/leafwing.rs @@ -794,9 +794,9 @@ fn test_validator_can_read_message_metadata() { mut receivers: Query<&mut MessageReceiver>>>, ) { for mut receiver in &mut receivers { - receiver.retain_received_messages(|received| { - // Metadata is reachable here (unlike `retain_messages`). - seen.0 = Some(received.remote_tick.0); + receiver.retain_received_messages(|metadata, _data| { + // Metadata is reachable here (read-only), unlike `retain_messages`. + seen.0 = Some(metadata.remote_tick.0); false // drop the message }); } diff --git a/crates/transport/messages/src/receive.rs b/crates/transport/messages/src/receive.rs index a55864cb5..56ba655d3 100644 --- a/crates/transport/messages/src/receive.rs +++ b/crates/transport/messages/src/receive.rs @@ -67,6 +67,19 @@ pub struct ReceivedMessage { pub message_id: Option, } +/// Read-only per-message metadata handed to +/// [`MessageReceiver::retain_received_messages`] validators alongside `&mut` +/// access to the message data. +#[derive(Debug, Clone, Copy)] +pub struct MessageMetadata { + /// Tick on the remote peer when the message was sent. + pub remote_tick: Tick, + /// Channel the message was sent on. + pub channel_kind: ChannelKind, + /// MessageId of the message, if the channel assigns one. + pub message_id: Option, +} + impl Default for MessageReceiver { fn default() -> Self { Self { recv: Vec::new() } @@ -104,18 +117,26 @@ impl MessageReceiver { } /// Like [`retain_messages`](Self::retain_messages), but the predicate also - /// gets the per-message metadata on [`ReceivedMessage`] — `remote_tick`, - /// `channel_kind`, `message_id` — which `retain_messages` hides. + /// gets the per-message [`MessageMetadata`] (`remote_tick`, `channel_kind`, + /// `message_id`) that `retain_messages` hides. /// /// Use this when validation needs the metadata, e.g. rate limiting, /// tick-window / staleness checks, replay diagnostics, or per-channel - /// policy. Mutate `received.data` to rewrite the message; return `false` to - /// drop it. + /// policy. The metadata is passed **by value (read-only)** — only the + /// message data is `&mut` (mutate to rewrite, return `false` to drop) — so a + /// validator can't accidentally rewrite the wire metadata. pub fn retain_received_messages( &mut self, - mut keep: impl FnMut(&mut ReceivedMessage) -> bool, + mut keep: impl FnMut(MessageMetadata, &mut M) -> bool, ) { - self.recv.retain_mut(|received| keep(received)); + self.recv.retain_mut(|received| { + let metadata = MessageMetadata { + remote_tick: received.remote_tick, + channel_kind: received.channel_kind, + message_id: received.message_id, + }; + keep(metadata, &mut received.data) + }); } pub fn num_messages(&self) -> usize { From 08ee8e04ca99e37b3f0c9ff93738275a9e653fcd Mon Sep 17 00:00:00 2001 From: Mika Mannermaa Date: Fri, 26 Jun 2026 00:31:19 +0700 Subject: [PATCH 5/5] docs(inputs): note validator ordering in add_input_validator Co-Authored-By: Claude Opus 4.8 --- crates/inputs/inputs/src/server.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/inputs/inputs/src/server.rs b/crates/inputs/inputs/src/server.rs index f690a5005..a449bb76c 100644 --- a/crates/inputs/inputs/src/server.rs +++ b/crates/inputs/inputs/src/server.rs @@ -137,6 +137,10 @@ pub enum InputSystems { /// `Query<&mut MessageReceiver>>` and calls /// [`MessageReceiver::retain_messages`]. This is sugar for /// `app.add_systems(PreUpdate, system.in_set(InputSystems::ValidateInputs))`. +/// +/// Validators in the set are unordered relative to each other. To make one run +/// before another, pass an ordered config — e.g. +/// `app.add_input_validator(my_validator.after(other_validator))`. pub trait InputValidationAppExt { fn add_input_validator( &mut self,