feat(inputs): opt-in authorize_controlled_targets helper (spoofed-target defense)#1526
Conversation
…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.
…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>
4c1c427 to
2a6f963
Compare
Audit: opt-in
|
…sages Audit (/audit-task on cBournhonesque#1526) found a correctness bug: the helper dropped a message once filtering removed all its targets (`!inputs.is_empty()`). But clients deliberately send empty `InputMessage`s as keepalives ("0 inputs is itself information", client.rs), and the server deliberately does NOT drop them (the guard is commented out: dropping makes "LastConfirmedTick really old → massive rollback"). The helper reintroduced that stall. Fix: strip unauthorized *targets* but always keep the message — spoofed entries are already removed before any rebroadcast, and an emptied message is just a keepalive the receive path handles. Also adds a `trace!` on drops (per Codex's review) so a missing `ControlledBy` is diagnosable, and a regression test for the fully-unauthorized / empty-after-filter path. 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>
…sages Audit (/audit-task on cBournhonesque#1526) found a correctness bug: the helper dropped a message once filtering removed all its targets (`!inputs.is_empty()`). But clients deliberately send empty `InputMessage`s as keepalives ("0 inputs is itself information", client.rs), and the server deliberately does NOT drop them (the guard is commented out: dropping makes "LastConfirmedTick really old → massive rollback"). The helper reintroduced that stall. Fix: strip unauthorized *targets* but always keep the message — spoofed entries are already removed before any rebroadcast, and an emptied message is just a keepalive the receive path handles. Also adds a `trace!` on drops (per Codex's review) so a missing `ControlledBy` is diagnosable, and a regression test for the fully-unauthorized / empty-after-filter path. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
e1d2eed to
af40d4e
Compare
…wned branches Closes the two test-quality follow-ups from the /audit-task pass on cBournhonesque#1526: - test_authorize_controlled_targets_exempts_host_client (host_server): a host-client (`is_local`) drives an entity with no `ControlledBy`; an observer chained after the helper confirms its target survives (the helper skips it). Verified discriminating — fails if the `is_local` guard is removed. - test_authorize_controlled_targets_passes_through_prespawned (client_server): a forged `InputTarget::PreSpawned` message; an observer confirms the target passes through the authorization filter. 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>
…sages Audit (/audit-task on cBournhonesque#1526) found a correctness bug: the helper dropped a message once filtering removed all its targets (`!inputs.is_empty()`). But clients deliberately send empty `InputMessage`s as keepalives ("0 inputs is itself information", client.rs), and the server deliberately does NOT drop them (the guard is commented out: dropping makes "LastConfirmedTick really old → massive rollback"). The helper reintroduced that stall. Fix: strip unauthorized *targets* but always keep the message — spoofed entries are already removed before any rebroadcast, and an emptied message is just a keepalive the receive path handles. Also adds a `trace!` on drops (per Codex's review) so a missing `ControlledBy` is diagnosable, and a regression test for the fully-unauthorized / empty-after-filter path. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…wned branches Closes the two test-quality follow-ups from the /audit-task pass on cBournhonesque#1526: - test_authorize_controlled_targets_exempts_host_client (host_server): a host-client (`is_local`) drives an entity with no `ControlledBy`; an observer chained after the helper confirms its target survives (the helper skips it). Verified discriminating — fails if the `is_local` guard is removed. - test_authorize_controlled_targets_passes_through_prespawned (client_server): a forged `InputTarget::PreSpawned` message; an observer confirms the target passes through the authorization filter. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ff8bcd6 to
c3a9cdc
Compare
|
Ready for review/merge (stacked on #1535). This is the opt-in Please merge #1535 first; I'll rebase this down to just the helper + tests afterward. CI: Doc + Lint green; Test job running (suite passes locally — 30/30 across 🤖 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>
…sages Audit (/audit-task on cBournhonesque#1526) found a correctness bug: the helper dropped a message once filtering removed all its targets (`!inputs.is_empty()`). But clients deliberately send empty `InputMessage`s as keepalives ("0 inputs is itself information", client.rs), and the server deliberately does NOT drop them (the guard is commented out: dropping makes "LastConfirmedTick really old → massive rollback"). The helper reintroduced that stall. Fix: strip unauthorized *targets* but always keep the message — spoofed entries are already removed before any rebroadcast, and an emptied message is just a keepalive the receive path handles. Also adds a `trace!` on drops (per Codex's review) so a missing `ControlledBy` is diagnosable, and a regression test for the fully-unauthorized / empty-after-filter path. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…wned branches Closes the two test-quality follow-ups from the /audit-task pass on cBournhonesque#1526: - test_authorize_controlled_targets_exempts_host_client (host_server): a host-client (`is_local`) drives an entity with no `ControlledBy`; an observer chained after the helper confirms its target survives (the helper skips it). Verified discriminating — fails if the `is_local` guard is removed. - test_authorize_controlled_targets_passes_through_prespawned (client_server): a forged `InputTarget::PreSpawned` message; an observer confirms the target passes through the authorization filter. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…trolled_targets Answers "how do I run my own validation after the authorization helper": order it with `.after(authorize_controlled_targets::<S>)`. Adds the pattern to the helper rustdoc and a test (test_custom_validator_ordered_after_authorize, verified discriminating — fails when ordered `.before`). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
c3a9cdc to
a2564b0
Compare
|
Rebased onto 🤖 Co-authored with Claude Code (Claude Opus 4.8) |
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
Reframes this PR as an opt-in helper for the spoofed-target defense, built on the validation seam in #1535 — rather than a compulsory, inline authorization check.
Per the discussion on #1531,
Controlled/ControlledByare optional, non-authoritative helpers (some games legitimately let several clients drive one entity), so lightyear should not enforce target authorization by default. Instead it offers a ready-made check a game can switch on:The defense
receive_input_messagereadsInputTarget::Entity(entity)off the wire and applies it to that entity'sInputBufferwith no ownership check — so a modified client can sendInputTarget::Entity(other_player)to drive an entity it doesn't control.authorize_controlled_targets::<S>is a publicInputSystems::ValidateInputssystem that strips everyInputTarget::Entitythe sender isn't authorized to control (not in itsControlledByRemote). The message itself is kept even if filtering removes all its targets — an emptyInputMessageis a legitimate keepalive that still carriesend_tickand advances confirmation; dropping it would stall the confirmed tick and risk a large rollback. Only the unauthorized targets are removed (before any rebroadcast). Host-client inputs (is_local) are exempt;InputTarget::PreSpawned(hash-identified) is passed through. It logs (trace!) when it strips targets, so a missingControlledByis diagnosable.To run your own validation after this helper, order it with
.after(authorize_controlled_targets::<S>).Tests
test_authorize_controlled_targets_helper— client 0 controls A and forges a target on uncontrolled B; A's authorized input lands (non-overblocking), B's spoofed input is dropped.test_authorize_controlled_targets_keeps_emptied_message— a fully-unauthorized message is stripped but kept (the keepalive isn't dropped); input doesn't land and the pipeline stays healthy.test_authorize_controlled_targets_exempts_host_client(host-server) — a host-client's inputs are not stripped (is_localbypass); verified discriminating.test_authorize_controlled_targets_passes_through_prespawned— a forgedPreSpawnedtarget survives the filter.test_custom_validator_ordered_after_authorize— a custom validator ordered.afterthe helper sees only authorized targets.Notes
ValidateInputsseam +MessageReceiver::retain_messagesthis builds on). Once feat(inputs): server-side input-validation seam (ValidateInputs) #1535 merges this rebases down to just the helper + test.ControlledBymandatory and break games/tests that don't use it — see the audit on feat(inputs): server-side input-validation seam (ValidateInputs) #1535). This is the opt-in middle ground: lightyear ships the check and documents the security tradeoff, the game decides.🤖 Generated with Claude Code
Co-authored with Claude Opus 4.8