fix(peer): reject unsafe game IDs in state marker paths

Scanner finding #12 ("state marker path escape"). The per-game state
helpers in `state_paths.rs` joined a raw game ID below
`<state_dir>/games/`. The public `setup_done_path` was therefore usable
with an absolute or parent-containing ID by an embedding caller, and the
legacy migration discovered IDs from directory names in the user's games
folder and joined them unconditionally. Every shipping caller today
validates its ID or takes it from the catalog, so this was a footgun
rather than an exploited hole, but the fix is small and removes the
reliance on every future caller remembering the rule.

Add `validate_game_state_id`, which rejects separators and NUL and then
delegates to `lanspread_db::content_manifest::validate_portable_component`
(the catalog's own rules: no `.`/`..`, no trailing dot or space, no control
or Windows-reserved characters, no Windows device names). Reusing the
catalog validator rather than a private copy guarantees that any ID the
catalog can publish is accepted here and that the two cannot drift apart.

`setup_done_path` now returns `eyre::Result<PathBuf>`; it is the only
state path the embedding application calls with an ID that may originate
from UI input. `launch_settings_applied_path` leaves the public API and
becomes `pub(crate)`; the two public launch-settings entry points
(`apply_launch_settings_once`, `mark_launch_settings_applied`) validate
the ID before any filesystem work. `game_state_dir` carries a
`debug_assert!` documenting the contract for internal callers without
turning a bad ID into a release-build panic; the migration test suite
exercises that assertion in debug builds.

Behaviour changes:
- Legacy migration logs a warning, counts a failure and leaves the legacy
  marker in place for a games-folder directory whose name is not a
  portable game ID, instead of creating state below it. A new test covers
  a trailing-dot directory name.
- The Windows launcher ignores a run request whose ID `setup_done_path`
  rejects, with a warning, mirroring the existing invalid-ID early return.

Tests cover catalog-valid IDs that must remain accepted (embedded dots,
spaces, `console.txt`, `com10`, non-ASCII) and unsafe IDs that must be
rejected (empty, `.`, `..`, separators, NUL, trailing dot or space,
device names, `a:b`).

This ports the fallible API from the parallel security branch (lanspread2
commits 4146a0e and 9f26c63) onto the validator this branch already
exports from `lanspread-db`.

Test plan:
- `cargo test -p lanspread-peer --lib`: 491 passed.
- `just clippy`: clean.
- On Windows, launch a game with a valid ID and confirm the setup marker
  is still written under `<app-data>/games/<id>/setup_done`.

Claude-Session: https://claude.ai/code/session_01QRkCv4a4GqkajyamxmbSuA
This commit is contained in:
ddidderr committed 2026-09-12 11:14:44 +02:00
1 parent 63aa4bc77c
commit 0a38dfbb19
7 files changed
+145 -13

No files matched your search

+46 -3
View File
@@ -206,7 +206,17 @@ fn note_legacy_install_intent(root: &Path) -> MigrationReport {
fn migrate_setup_marker(state_dir: &Path, id: &str, root: &Path) -> MigrationReport {
let mut report = MigrationReport::default();
let legacy_path = root.join("local").join(LEGACY_FIRST_START_DONE_FILE);
let target_path = setup_done_path(state_dir, id);
// Roots are discovered from directory names in the games folder, so an
// arbitrary name must be refused here rather than joined below the state
// directory.
let target_path = match setup_done_path(state_dir, id) {
Ok(path) => path,
Err(error) => {
log::warn!("Refusing setup marker migration for unsafe game ID {id:?}: {error}");
report.failures += 1;
return report;
}
};
match scoped_blocking(|| migrate_empty_marker(&legacy_path, &target_path)) {
Ok(MigrationOutcome::Migrated) => {
@@ -521,7 +531,11 @@ mod tests {
assert_eq!(report.install_intents_migrated, 0);
assert_eq!(report.failures, 2);
assert_eq!(report.setup_markers_migrated, 1);
assert!(setup_done_path(state.path(), "game").is_file());
assert!(
setup_done_path(state.path(), "game")
.expect("setup marker path should be valid")
.is_file()
);
assert!(legacy_intent.exists());
assert!(legacy_tmp.exists());
assert!(!legacy_setup.exists());
@@ -555,6 +569,32 @@ mod tests {
assert_eq!(second.failures, 0);
}
#[tokio::test]
async fn unsafe_root_name_is_refused_without_panicking_or_touching_state() {
let games = TempDir::new("lanspread-migration-games");
let state = TempDir::new("lanspread-migration-state");
// A trailing dot is a legal directory name on Unix but is not a
// portable game ID; joining it below the state directory must be
// refused rather than attempted.
let legacy_marker = games
.path()
.join("game.")
.join("local")
.join(LEGACY_FIRST_START_DONE_FILE);
write_file(&legacy_marker, b"");
let report = migrate_legacy_state(games.path(), state.path()).await;
assert_eq!(report.games_checked, 1);
assert_eq!(report.failures, 1);
assert_eq!(report.setup_markers_migrated, 0);
assert!(legacy_marker.exists(), "legacy marker must be kept");
assert!(
!state.path().join("games").exists(),
"no per-game state may be created for an unsafe ID"
);
}
#[tokio::test]
async fn app_state_wins_over_legacy_per_game_state() {
let games = TempDir::new("lanspread-migration-games");
@@ -571,7 +611,10 @@ mod tests {
&legacy_intent_path,
br#"{"schema_version":1,"state":"Installing"}"#,
);
write_file(&setup_done_path(state.path(), "game"), b"");
write_file(
&setup_done_path(state.path(), "game").expect("setup marker path should be valid"),
b"",
);
write_file(&legacy_setup, b"");
let report = migrate_legacy_state(games.path(), state.path()).await;