Tighten IPC socket permissions (chmod 0o700/0o600)

fix(security): the daemon's IPC socket was world-accessible. Any local
user could speak the IPC protocol and type arbitrary text into game
windows via Command::Type.

- src/profile.rs: add chmod_runtime_dir() and chmod_socket(path) helpers
- src/session.rs: call chmod_runtime_dir() after create_dir_all, and
  chmod_socket() right after UnixListener::bind succeeds
- Tests: chmod_socket_sets_0o600, chmod_runtime_dir_sets_0o700 (saved/
  restored live dir perms to avoid clobbering a real session)
- CHANGELOG.md: security note

cargo test 96+/0; clippy clean.
This commit is contained in:
en 2026-09-16 05:45:32 +02:00
parent 20f0160799
commit cece41d2ee
3 changed files with 76 additions and 0 deletions

View File

@ -51,3 +51,9 @@
> Note: After this change, `bind: "interact"` is a single Alt+J send (game_binds.interact). > 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. > 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)`.

View File

@ -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<P: AsRef<std::path::Path>>(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)] #[cfg(test)]
mod tests { mod tests {
use super::NormRect; use super::NormRect;
@ -490,3 +522,39 @@ mod tests {
assert_eq!(r.to_pixels(100, 50, 200, 100), (110, 70, 80, 40)); 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));
}
}

View File

@ -43,6 +43,7 @@ impl Session {
pub async fn run(profile: Profile, sock: PathBuf) -> Result<()> { pub async fn run(profile: Profile, sock: PathBuf) -> Result<()> {
std::fs::create_dir_all(runtime_dir()).ok(); std::fs::create_dir_all(runtime_dir()).ok();
crate::profile::chmod_runtime_dir();
if sock.exists() { if sock.exists() {
let _ = std::fs::remove_file(&sock); 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()))?; let listener = UnixListener::bind(&sock).with_context(|| format!("bind {}", sock.display()))?;
crate::profile::chmod_socket(&sock);
tracing::info!("ipc {}", sock.display()); tracing::info!("ipc {}", sock.display());
let s1 = session.clone(); let s1 = session.clone();