Skip to content

feat(inputs): opt-in authorize_controlled_targets helper (spoofed-target defense)#1526

Merged
cBournhonesque merged 4 commits into
cBournhonesque:mainfrom
mmannerm:fix/input-target-authorization
Jun 26, 2026
Merged

feat(inputs): opt-in authorize_controlled_targets helper (spoofed-target defense)#1526
cBournhonesque merged 4 commits into
cBournhonesque:mainfrom
mmannerm:fix/input-target-authorization

Conversation

@mmannerm

@mmannerm mmannerm commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

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 / ControlledBy are 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:

app.add_input_validator(authorize_controlled_targets::<MySequence>);

The defense

receive_input_message reads InputTarget::Entity(entity) off the wire and applies it to that entity's InputBuffer with no ownership check — so a modified client can send InputTarget::Entity(other_player) to drive an entity it doesn't control.

authorize_controlled_targets::<S> is a public InputSystems::ValidateInputs system that strips every InputTarget::Entity the sender isn't authorized to control (not in its ControlledByRemote). The message itself is kept even if filtering removes all its targets — an empty InputMessage is a legitimate keepalive that still carries end_tick and 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 missing ControlledBy is 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_local bypass); verified discriminating.
  • test_authorize_controlled_targets_passes_through_prespawned — a forged PreSpawned target survives the filter.
  • test_custom_validator_ordered_after_authorize — a custom validator ordered .after the helper sees only authorized targets.

Notes

🤖 Generated with Claude Code
Co-authored with Claude Opus 4.8

mmannerm added a commit to mmannerm/lightyear that referenced this pull request Jun 25, 2026
…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.
mmannerm added a commit to mmannerm/lightyear that referenced this pull request Jun 25, 2026
…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>
@mmannerm mmannerm force-pushed the fix/input-target-authorization branch from 4c1c427 to 2a6f963 Compare June 25, 2026 16:41
@mmannerm mmannerm changed the title fix(inputs): authorize InputMessage target against ControlledByRemote (spoofed-target defense) feat(inputs): opt-in authorize_controlled_targets helper (spoofed-target defense) Jun 25, 2026
@mmannerm

mmannerm commented Jun 25, 2026

Copy link
Copy Markdown
Contributor Author

Audit: opt-in authorize_controlled_targets helper (#1526)

Verdict: [APPROVE] — the BLOCK below (empty-keepalive drop) was fixed; the test follow-ups are closed. Re-run at c3a9cdcf (rebased on #1535 08ee8e04, + validator-ordering docs/test). Merge after #1535.

Build / test / lint (re-run at c3a9cdcf)

  • build: ✓ · fmt: ✓ · clippy (-D warnings, --all-targets): ✓
  • test (cargo test -p lightyear_tests --lib client_server::input host_server::input, serial): ✓ 30 passed, 0 failed (+keepalive, +host-client exemption, +PreSpawned, +ordering).

Resolution

[was BLOCK] helper dropped legitimately-empty keepalive messages → confirmed-tick stall / massive rollback

Verified: clients deliberately send empty InputMessages ("0 inputs is itself information", client.rs:721); the server deliberately does not drop them (guard commented out, server.rs:331-333: dropping → "LastConfirmedTick really old → massive rollback"). The helper's !message.inputs.is_empty() dropped pre-filter-empty messages, reintroducing the stall.

Fixed: the helper now strips unauthorized targets but always keeps the message — spoofed entries are already removed before any rebroadcast, and an emptied message is a valid keepalive the receive path handles. Removes the empty-vs-emptied ambiguity entirely. Added a regression test (test_authorize_controlled_targets_keeps_emptied_message) and a trace! on drops (Codex P2 — a missing ControlledBy is now diagnosable).

Findings (non-blocking)

Security (invariants): This is the spoofed-target defense; logic sound — authorized InputTarget::Entity kept, unauthorized stripped before rebroadcast, host-client (is_local) exempt, PreSpawned passed through. No new vulnerability; opt-in, no default behavior change.

Simplicity: SIMPLIFY — With<Connected> on the query is arguably dead weight (a disconnected receiver has no messages), but it mirrors receive_input_message for consistency; low value either way. Kept.

Test quality: all documented branches covered: authorized-lands, spoofed-stripped, empty-after-filter keepalive, host-client is_local exemption (host-server test, verified discriminating), PreSpawned pass-through, and custom-validator ordering via .after(authorize_controlled_targets::<S>) (also verified discriminating — fails when ordered .before).


Run by /audit-task. Re-runs edit this comment in place.

mmannerm added a commit to mmannerm/lightyear that referenced this pull request Jun 25, 2026
…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>
mmannerm added a commit to mmannerm/lightyear that referenced this pull request Jun 25, 2026
…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>
mmannerm added a commit to mmannerm/lightyear that referenced this pull request Jun 25, 2026
…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>
@mmannerm mmannerm force-pushed the fix/input-target-authorization branch from e1d2eed to af40d4e Compare June 25, 2026 17:13
mmannerm added a commit to mmannerm/lightyear that referenced this pull request Jun 25, 2026
…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>
mmannerm added a commit to mmannerm/lightyear that referenced this pull request Jun 25, 2026
…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>
mmannerm added a commit to mmannerm/lightyear that referenced this pull request Jun 25, 2026
…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>
mmannerm added a commit to mmannerm/lightyear that referenced this pull request Jun 25, 2026
…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>
@mmannerm mmannerm force-pushed the fix/input-target-authorization branch from ff8bcd6 to c3a9cdc Compare June 25, 2026 17:32
@mmannerm

Copy link
Copy Markdown
Contributor Author

Ready for review/merge (stacked on #1535). This is the opt-in authorize_controlled_targets helper — not default-on (ControlledBy stays optional) — keeping emptied messages as keepalives, stripping only unauthorized targets before rebroadcast, with a trace! on drops. Coverage: authorized-lands, spoofed-stripped, empty-keepalive, host-client is_local exemption, PreSpawned pass-through, and custom-validator ordering via .after(...). Codex review + /audit-task both APPROVE, no open findings.

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 client_server::input + host_server::input).

🤖 Co-authored with Claude Code (Claude Opus 4.8)

mmannerm and others added 4 commits June 26, 2026 08:48
…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>
@mmannerm mmannerm force-pushed the fix/input-target-authorization branch from c3a9cdc to a2564b0 Compare June 26, 2026 01:50
@mmannerm

Copy link
Copy Markdown
Contributor Author

Rebased onto main now that #1535 is merged — this PR is down to just the authorize_controlled_targets helper + tests (the seam commits dropped out, as they're now in main). No conflicts; ready for review. Local suite green (30/30 across client_server::input + host_server::input); CI re-running.

🤖 Co-authored with Claude Code (Claude Opus 4.8)

@cBournhonesque cBournhonesque merged commit 522ec6d into cBournhonesque:main Jun 26, 2026
4 checks passed
Dastari added a commit to Dastari/lightyear that referenced this pull request Jun 27, 2026
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>
Dastari added a commit to Dastari/lightyear that referenced this pull request Jun 27, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants