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:
@@ -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)?;
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user