Skip to content

feat(inputs): server-side input-validation seam (ValidateInputs)#1535

Merged
cBournhonesque merged 5 commits into
cBournhonesque:mainfrom
mmannerm:feat/input-validation-example
Jun 25, 2026
Merged

feat(inputs): server-side input-validation seam (ValidateInputs)#1535
cBournhonesque merged 5 commits into
cBournhonesque:mainfrom
mmannerm:feat/input-validation-example

Conversation

@mmannerm

@mmannerm mmannerm commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

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 SystemParam access, runs between message receipt and input buffering and may mutate or drop received InputMessages in place.

MessageSystems::Receive  →  InputSystems::ValidateInputs (user systems)  →  InputSystems::ReceiveInputs (buffer apply)

No new resource and no rewrite of receive_input_message — systems intercept the existing MessageReceiver<InputMessage<S>> (a Vec of received messages). No behavior change by default: the seam is empty until a game registers a validator.

This PR is an API seam, not a spoofed-target security fix. It does not enforce target authorization by default — ControlledBy stays an optional, non-authoritative helper (per the maintainer on #1531). A game that wants target authorization implements it on the seam (see the test below). #1526 remains unresolved — the decision there is whether lightyear ships default protection, an opt-in helper, or docs/example only. An interim revision of this PR had a compulsory AuthorizeInputs pass; it was removed (it made ControlledBy required and broke existing input tests).

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 after MessageSystems::Receive and before InputSystems::ReceiveInputs.
  • app.add_input_validator(system) — schedules a plain system into ValidateInputs (full ECS access).
fn reject_inputs(
    reject: Res<RejectInputs>,                        // arbitrary game state
    mut receivers: Query<&mut MessageReceiver<InputMessage<MySequence>>>,
) {
    if !reject.0 { return; }
    for mut r in &mut receivers {
        r.retain_messages(|_msg| false);              // clamp/inspect instead, normally
    }
}
app.add_input_validator(reject_inputs);

Tests

  • test_input_validator_system_can_drop_messages — a validator with ECS access (Res) drops messages via retain_messages; the input never reaches the server ActionState.
  • test_user_validator_can_authorize_targets — a game-supplied validator implements ControlledBy-based target authorization (drops InputTarget::Entity the 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 in ValidateInputs" point.
  • test_validator_can_read_message_metadata — a validator reads a message's remote_tick via retain_received_messages (read-only MessageMetadata) and drops it; asserts the metadata was observable and the drop took effect.

Notes

Discussion / rationale: #1531.

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

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.
@mmannerm

mmannerm commented Jun 25, 2026

Copy link
Copy Markdown
Contributor Author

Co-authored critical pass from me + OpenAI Codex, refreshed at #1535 08ee8e04 and #1526 c3a9cdcf.

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.
The issue body still says retain_received_messages(|&mut ReceivedMessage<M>| -> bool) in the resolved metadata note, but the actual API is now retain_received_messages(|MessageMetadata, &mut M| -> bool). It also still lists auth-helper telemetry as an open question even though #1526 now emits a trace! when it strips targets. This is not a code blocker, but the RFC should be refreshed so future readers do not reconstruct the wrong API contract.

[P3] The #1526 audit/status comment appears stale relative to the current head.
The audit comment says the green run was at ff8bcd6d, while the current #1526 head is c3a9cdcf after the validator-ordering docs/test commit. Since that last change includes a new test, I would re-run/update the audit marker before merge. I did not independently re-run the full test suite in this refresh.

Verdict

#1535 looks ready from an API-design standpoint: retain_messages covers simple mutation/drop, retain_received_messages covers metadata-aware validation without allowing metadata mutation, and ValidateInputs gives games/anti-cheat systems full ECS access without forcing Lightyear policy.

#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

@mmannerm

mmannerm commented Jun 25, 2026

Copy link
Copy Markdown
Contributor Author

Audit: server-side input-validation seam (#1535)

Verdict: [APPROVE] — re-run at 08ee8e04. History: BLOCK (default-on authorization) → decoupled (a716a7e5); retain_received_messages added (e4ba6144) → refined to a read-only MessageMetadata view (a4237230); validator-ordering docs (08ee8e04, docs-only). The full bug/security/simplicity/test-quality lenses were run at e4ba6144/a4237230; the delta since is documentation only, so they still hold. Merge is the maintainer's call; remaining items are non-blocking.

Build / test / lint (re-run at 08ee8e04)

  • build: ✓ · fmt: ✓ · clippy (-D warnings, --all-targets): ✓
  • test (cargo test -p lightyear_tests --lib client_server::input, serial): ✓ 20 passed, 0 failed.
  • Intent: PR body matches the diff — lists all three seam tests (drop / user-authorization / metadata-read).

Resolution

The compulsory AuthorizeInputs set + authorize_input_targets system were removed. Per the maintainer (#1531, 16:01), ControlledBy is an optional helper, not a required input component — and enabling authorization by default broke 8 existing tests that don't set it. #1535 now ships the seam only (retain_messages + ValidateInputs + add_input_validator), a no-behavior-change addition. Authorization is demonstrated as a user-space validator in test_user_validator_can_authorize_targets, which also asserts the authorized input still lands (closing the non-overblocking gap) and exercises the retain_messages per-target mutation path.

Findings (all non-blocking)

Original BLOCK (resolved) + the lenses
  • [resolved] Test gate / intent: default-on AuthorizeInputs made ControlledBy compulsory and broke 8 tests; undisclosed breaking change. Fixed by decoupling.
  • Code review: no ≥80%-confidence logic bugs (retain_messages metadata, ordering, host-client bypass, entity-map edge all sound). Corroborated by @mmannerm + Codex's review on this PR.
  • Security (invariants): lightyear trust boundary — the seam itself adds no default enforcement (intended); no rollback-determinism / server-authority / asset-load issue; no new vulnerability.

Simplicity (open): add_input_validator is thin sugar over add_systems(...).in_set(InputSystems::ValidateInputs) — keep for discoverability or drop for a smaller surface (maintainer's call).

Codex's review: metadata exposure → addressed (retain_received_messages with the read-only MessageMetadata view). The mutability-contract and add_input_validator naming points are resolved/maintainer's-call.

These are design-discussion points already raised on this PR / #1531; I have not auto-filed them as issues on the upstream repo.


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

…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 mmannerm changed the title feat(inputs): server-side input-validation seam (ValidateInputs + AuthorizeInputs) feat(inputs): server-side input-validation seam (ValidateInputs) Jun 25, 2026
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>
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>
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>
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>
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

Copy link
Copy Markdown
Contributor Author

Ready for review/merge. This is the policy-free ValidateInputs seam — retain_messages, retain_received_messages (read-only MessageMetadata), and add_input_validator — with no default behavior change. Both your co-authored Codex review and the /audit-task pass are at APPROVE with no open findings.

Suggested merge order: this first, then #1526 rebases down to just the authorize_controlled_targets helper + tests.

CI: Doc + Lint green; the full Test job is running (the suite passes locally — cargo test -p lightyear_tests --lib client_server::input, 20/20).

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

@cBournhonesque cBournhonesque merged commit f685642 into cBournhonesque:main Jun 25, 2026
4 checks passed
mmannerm added a commit to mmannerm/lightyear that referenced this pull request Jun 26, 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>
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