Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion crates/inputs/inputs/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ pub mod prelude {
pub mod server {
pub use crate::server::{
InputRebroadcaster, InputSystems, InputValidationAppExt, ServerInputConfig,
ServerInputPlugin,
ServerInputPlugin, authorize_controlled_targets,
};
}
}
71 changes: 71 additions & 0 deletions crates/inputs/inputs/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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};

Expand Down Expand Up @@ -158,6 +160,75 @@ impl InputValidationAppExt for App {
}
}

/// Opt-in [`InputSystems::ValidateInputs`] system that strips every
/// `InputTarget::Entity` the sending peer is **not** authorized to control —
/// i.e. not a member of its [`ControlledByRemote`]. This is the spoofed-target
/// defense: a modified client cannot forge `InputTarget::Entity(other_player)`
/// to drive an entity it doesn't own. The message itself is kept (even if
/// filtering emptied it) — an empty input message is a legitimate keepalive the
/// receive path relies on; only the unauthorized targets are removed.
///
/// lightyear does **not** enable this by default. `ControlledBy` is an optional
/// helper for modeling input ownership, not a mandatory component, and some
/// games legitimately let several clients drive one entity. Register this only
/// if your game uses `ControlledBy` and wants the check:
///
/// ```ignore
/// app.add_input_validator(authorize_controlled_targets::<MySequence>);
/// ```
///
/// To run **your own** validation after this one — so it only sees authorized
/// targets — order it with `.after(authorize_controlled_targets::<S>)`
/// (validators in [`InputSystems::ValidateInputs`] are otherwise unordered):
///
/// ```ignore
/// app.add_input_validator(authorize_controlled_targets::<MySequence>);
/// app.add_input_validator(my_validator.after(authorize_controlled_targets::<MySequence>));
/// ```
///
/// - Host-client inputs (`RemoteId::is_local`) are trusted in-process and
/// skipped.
/// - `InputTarget::PreSpawned` is identified by a hash, not an entity id, so it
/// is passed through here (binding a prespawn to an owner is out of scope).
pub fn authorize_controlled_targets<S: ActionStateSequence>(
mut receivers: Query<
(
&RemoteId,
Option<&ControlledByRemote>,
&mut MessageReceiver<InputMessage<S>>,
),
With<Connected>,
>,
) {
for (client_id, controlled_by_remote, mut receiver) in receivers.iter_mut() {
if client_id.is_local() {
continue;
}
receiver.retain_messages(|message| {
let before = message.inputs.len();
message.inputs.retain(|data| match data.target {
InputTarget::Entity(entity) => controlled_by_remote
.is_some_and(|controlled| controlled.collection().contains(&entity)),
InputTarget::PreSpawned(_) => true,
});
let dropped = before - message.inputs.len();
if dropped > 0 {
trace!(
?client_id,
dropped, "authorize_controlled_targets: stripped unauthorized input targets"
);
}
// Keep the message even if filtering emptied it. An empty input
// message is a legitimate keepalive (it still carries `end_tick`,
// which the receive path needs — dropping it stalls the confirmed
// tick and can trigger a large rollback). Only the unauthorized
// *targets* are removed; the spoofed entries are already gone before
// any rebroadcast.
true
});
}
}

/// 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
Expand Down
Loading