fix(peer): fail instead of falling back to /tmp for the state directory

Security audit finding EXP2-SEC-06. When no explicit state directory,
`LANSPREAD_STATE_DIR`, `HOME` or `USERPROFILE` was available,
`resolve_state_dir` silently used `<temp_dir>/lanspread`. On a
multi-user machine that is a predictable, world-writable location:
another local user could pre-create it and then read or replace the
Ed25519 peer identity and the download ownership journals stored there.

Both shipping callers always provide a directory (the Tauri app passes
its app-data path, the CLI its `--state-dir`), so the fallback was only
reachable in unusual environments. Rather than derive a UID-specific
temp path, peer startup now returns an error naming the accepted
sources. This is the same fail-closed stance the codebase already takes
for a malformed sharing policy.

Test plan: `just test`. Manually, `LANSPREAD_STATE_DIR= HOME=
lanspread-peer-cli ...` without `--state-dir` must refuse to start with
a clear message; normal `just run` and `just peer-cli-run` are
unaffected.

Claude-Session: https://claude.ai/code/session_017C3Nbgwpdm3YNwZhhFLHwg
This commit is contained in:
2026-09-02 22:34:40 +02:00
parent ec35826173
commit 5c9f8bb321
2 changed files with 34 additions and 6 deletions
+1 -1
View File
@@ -595,7 +595,7 @@ pub fn start_peer_with_options(
stream_install_provider,
local_network_sharing,
} = options;
let state_dir = resolve_state_dir(state_dir.as_deref());
let state_dir = resolve_state_dir(state_dir.as_deref())?;
let requested_game_dir = game_dir.into();
let game_dir = canonicalize_game_dir(&requested_game_dir)?;
install::intent::scan_active_install_intents(&state_dir, &game_dir)?;
+33 -5
View File
@@ -15,20 +15,34 @@ pub(crate) const LEGACY_DOWNLOAD_OWNERSHIP_TMP_FILE: &str = "download_ownership.
pub(crate) const LEGACY_DOWNLOAD_OWNERSHIP_RECOVERY_REQUIRED_FILE: &str =
"download_ownership.recovery-required";
pub(crate) fn resolve_state_dir(explicit: Option<&Path>) -> PathBuf {
/// Resolves the directory that holds the peer identity, ownership journals and
/// other durable state.
///
/// Precedence: an explicit path from the embedding application (the Tauri
/// app passes its app-data directory, the CLI its `--state-dir`), then
/// `LANSPREAD_STATE_DIR`, then `$HOME`/`%USERPROFILE%/.lanspread`.
///
/// # Errors
///
/// Returns an error when none of these sources is available. State holds the
/// private identity key, so it is never placed in a shared, world-writable
/// temporary directory where another local user could pre-create it.
pub(crate) fn resolve_state_dir(explicit: Option<&Path>) -> eyre::Result<PathBuf> {
if let Some(dir) = explicit {
return dir.to_path_buf();
return Ok(dir.to_path_buf());
}
if let Some(dir) = std::env::var_os("LANSPREAD_STATE_DIR") {
return PathBuf::from(dir);
return Ok(PathBuf::from(dir));
}
if let Some(home) = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")) {
return PathBuf::from(home).join(".lanspread");
return Ok(PathBuf::from(home).join(".lanspread"));
}
std::env::temp_dir().join("lanspread")
eyre::bail!(
"no state directory: pass one explicitly or set LANSPREAD_STATE_DIR, HOME, or USERPROFILE"
)
}
pub(crate) fn peer_identity_path(state_dir: &Path) -> PathBuf {
@@ -161,3 +175,17 @@ fn hex_encode(bytes: &[u8]) -> String {
}
encoded
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn explicit_state_dir_takes_precedence() {
let explicit = Path::new("/explicit/state");
assert_eq!(
resolve_state_dir(Some(explicit)).expect("explicit path resolves"),
explicit
);
}
}