diff --git a/CHANGELOG.md b/CHANGELOG.md index 3564dc5..a154002 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,3 +51,9 @@ > Note: After this change, `bind: "interact"` is a single Alt+J send (game_binds.interact). > The full chain trigger is now `bind: "smart_interact"`. Examples and tests updated. + +- **Security:** IPC runtime dir is now `chmod 0o700` and the Unix socket is + `chmod 0o600` immediately after `UnixListener::bind`. Any local user with + read access could speak our IPC protocol (`Command::Type` etc.); this is + the cheapest defense. Helpers: `profile::chmod_runtime_dir()` and + `profile::chmod_socket(path)`. diff --git a/src/profile.rs b/src/profile.rs index 37222ee..445aa6a 100644 --- a/src/profile.rs +++ b/src/profile.rs @@ -462,6 +462,38 @@ pub fn runtime_dir() -> PathBuf { } } +/// `chmod 0o700` the runtime dir so only this user can read/write. +/// +/// Any local user with read access to the socket could speak our IPC +/// protocol (including `Command::Type`, which is wide-open text injection +/// into game windows). Permissions are the cheapest defense. +pub fn chmod_runtime_dir() { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let dir = runtime_dir(); + let _ = std::fs::set_permissions( + &dir, + std::fs::Permissions::from_mode(0o700), + ); + } +} + +/// `chmod 0o600` the IPC socket so only this user can connect. +/// +/// Belt-and-braces with `chmod_runtime_dir`: the dir alone is not enough +/// if other shared paths leaked earlier. +pub fn chmod_socket>(path: P) { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions( + path.as_ref(), + std::fs::Permissions::from_mode(0o600), + ); + } +} + #[cfg(test)] mod tests { use super::NormRect; @@ -490,3 +522,39 @@ mod tests { assert_eq!(r.to_pixels(100, 50, 200, 100), (110, 70, 80, 40)); } } + +#[cfg(unix)] +#[test] +fn chmod_socket_sets_0o600() { + use std::os::unix::fs::PermissionsExt; + let dir = std::env::temp_dir().join("enboxer-test-chmod"); + std::fs::create_dir_all(&dir).unwrap(); + let sock = dir.join("test.sock"); + std::fs::write(&sock, b"").unwrap(); + std::fs::set_permissions(&sock, std::fs::Permissions::from_mode(0o644)).unwrap(); + chmod_socket(&sock); + let m = std::fs::metadata(&sock).unwrap().permissions().mode() & 0o777; + assert_eq!(m, 0o600, "expected 0o600, got {m:o}"); + std::fs::remove_file(&sock).ok(); + std::fs::remove_dir(&dir).ok(); +} + +#[cfg(unix)] +#[test] +fn chmod_runtime_dir_sets_0o700() { + use std::os::unix::fs::PermissionsExt; + // Save and restore the real dir perms around the test so we don't break + // the live session if it happens to share XDG_RUNTIME_DIR. + let dir = runtime_dir(); + let _ = std::fs::create_dir_all(&dir); + let saved = std::fs::metadata(&dir).ok().map(|m| m.permissions().mode() & 0o777); + // Force 0o755 so the helper actually has to change it. + let _ = std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755)); + chmod_runtime_dir(); + let m = std::fs::metadata(&dir).unwrap().permissions().mode() & 0o777; + assert_eq!(m, 0o700, "expected 0o700, got {m:o}"); + if let Some(s) = saved { + let _ = std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(s)); + } +} + diff --git a/src/session.rs b/src/session.rs index 755c8b2..9527ee3 100644 --- a/src/session.rs +++ b/src/session.rs @@ -43,6 +43,7 @@ impl Session { pub async fn run(profile: Profile, sock: PathBuf) -> Result<()> { std::fs::create_dir_all(runtime_dir()).ok(); + crate::profile::chmod_runtime_dir(); if sock.exists() { let _ = std::fs::remove_file(&sock); } @@ -69,6 +70,7 @@ pub async fn run(profile: Profile, sock: PathBuf) -> Result<()> { } let listener = UnixListener::bind(&sock).with_context(|| format!("bind {}", sock.display()))?; + crate::profile::chmod_socket(&sock); tracing::info!("ipc {}", sock.display()); let s1 = session.clone();