Acts on the 2026-08-05 host security review. 36 of its 38 findings; the two exceptions are recorded below and in the review doc. The review's headline is that `plugin_may_access` was the one authorization gate in the system that was allow-by-default — a hand-maintained denylist of route prefixes, where every sibling gate is deny-by-default. Its own doc comment names the two capabilities it exists to withhold, and both were reachable one route over, because ~1450 commits of new routes were added and the list was never one of the things anyone remembered to update. So the gate is now an allowlist, and a test walks the live route table and fails the build for any route that has not been deliberately classified for both non-admin lanes. That test is the actual fix: it is what stops the next route from arriving pre-authorized. Route reachability and field authority turned out to be different questions. A provider plugin has to be able to reconcile its own library entries — that is what a scanner plugin IS — but `prep` and a `command` launch inside that payload are handed to `/bin/sh -c` as the host user, and every execution site documents them as operator-typed. Requests now carry the lane that authorized them, and those two fields are refused to everyone but the operator's own token. The art proxy read any absolute path off disk in the host process, which on Windows is LocalSystem, from a path the plugin lane could write and then read back — so it yielded `mgmt-token`, which is full admin. It now serves only real images (extension AND magic bytes, so a renamed secret fails), only from inside an allowed root, only after canonicalization, and never over UNC; and a path it would refuse to serve can no longer be persisted in the first place. On Windows, the config-dir hardening was skipped exactly when it was needed — it ran only in the branch that CREATES host.env, so the case it was written for (a local user pre-created the directory and planted one) was the one case it never ran in. It is now unconditional and first, an existing host.env is re-owned, and the inheritable OWNER RIGHTS ACE that kept an attacker's files theirs after the directory was re-owned is gone. The identity and token readers were hardening the directory only on the path that GENERATED a new secret, so a planted cert/key or token was adopted verbatim and permanently; they harden before the first read now. `ensure_admin_only_source` is implemented. The 2026-07-05 audit recorded it as FIXED and it was in no commit in this repository's history — the local EoP it described was live, and it is the payload half of the config-dir chain above. Also: the three input planes are bounded and lossy like the mic plane on the same loop already was; Android's library client no longer accepts any publicly-trusted certificate for the pinned host; the usbip vhci nodes get their own group instead of riding on `input`, which every packaging scriptlet tells users to join; a registry URL can no longer inject a TOML table into bunfig.toml; the pairing cooldown is charged before the arming state is read, so armed/disarmed is no longer a free oracle; and the whole Low tier, of which the two worth naming are a clipboard MIME NUL that panicked the host on one control message, and an unauthenticated global logout that let any LAN peer sign the operator out on a loop. NOT fixed, deliberately: H-3 (plugin UIs framed allow-same-origin). Dropping allow-same-origin does not work: the document's origin goes opaque, its subresource requests are then cross-site, the SameSite=Lax session cookie is not sent, and every plugin asset 302s to /login. The "open in new tab" link is the same escalation with no iframe at all, so the sandbox attribute is not where this gets fixed either. It needs a second listener — a distinct origin that is still the same site — which changes the console's deploy model and wants on-glass validation. The mechanism and the dead end are written down at the iframe. H-6 registry authentication, whose other half lives in unom/infra. The in-repo halves are done: workflow_dispatch inputs no longer interpolate into run: blocks (one of them in the step holding UPDATE_MANIFEST_KEY), and the syft installer is pinned to its tag instead of main. Digest pinning is left until the registry is authenticated, because a tag — content-keyed or not — can simply be overwritten while anonymous pushes are accepted. M-5 is half done: the oracle is closed, but binding the arming window needs the console to learn the fingerprint first, which is a knock-then-bind flow rather than an edit. Verified: cargo fmt --all --check clean; cargo check --all-targets green on Linux and on Windows (confirmed non-vacuous — a planted type error in windows/install.rs fails the build); scripts/xcheck.sh windows check green; cargo test -p punktfunk-host --bins 416 passed, the single failure being gamestream::stream::tests::sender_delivers_batches, the known qemu-environmental UDP-loopback flake that fails identically on clean main in the same container; cargo test -p pf-clipboard 13 passed; web console typechecks.
263 lines
13 KiB
Rust
263 lines
13 KiB
Rust
//! Host config-dir + owner-private file helpers — a leaf crate so the subsystem crates
|
|
//! (`pf-media`, `pf-vdisplay`) and the orchestrator can all reach them WITHOUT depending on the
|
|
//! `gamestream` module they used to live in (plan §2.4 / §W6: the secret helpers were shared
|
|
//! vocabulary parked above their consumers in the junk drawer). Pure std + `tracing`; no I/O stack.
|
|
//!
|
|
//! - [`config_dir`] resolves the per-host config directory (XDG / `%ProgramData%`, `PUNKTFUNK_CONFIG_DIR` override).
|
|
//! - [`create_private_dir`] makes it owner-private (0700 / restrictive DACL).
|
|
//! - [`write_secret_file`] writes an owner-only secret (0600 / SYSTEM+Admins DACL).
|
|
#![forbid(unsafe_code)]
|
|
|
|
use std::path::PathBuf;
|
|
|
|
/// The shared path of the file where the gamescope backend relays the nested session's
|
|
/// `LIBEI_SOCKET` (gamescope's EIS server) for the input injector: `$XDG_RUNTIME_DIR/
|
|
/// punktfunk-gamescope-ei` (per-user 0700), or `/tmp/…` when the runtime dir is unset. It is a
|
|
/// **contract shared** by the gamescope producer (`pf-vdisplay`, which writes it under the session
|
|
/// env lock) and the libei consumer (`pf-inject`, which reads it after the session env is applied) —
|
|
/// a leaf so neither subsystem crate has to reach into the other (plan §W6). Linux-only.
|
|
#[cfg(target_os = "linux")]
|
|
pub fn gamescope_ei_socket_file() -> PathBuf {
|
|
match std::env::var_os("XDG_RUNTIME_DIR").filter(|s| !s.is_empty()) {
|
|
Some(rt) => PathBuf::from(rt).join("punktfunk-gamescope-ei"),
|
|
None => PathBuf::from("/tmp/punktfunk-gamescope-ei"),
|
|
}
|
|
}
|
|
|
|
/// The host config dir (host identity, pairing state, mgmt token, library) — created on demand.
|
|
/// Linux: `$XDG_CONFIG_HOME/punktfunk` or `~/.config/punktfunk`. Windows: `%ProgramData%\punktfunk`
|
|
/// (machine-wide — the SYSTEM service and the interactive user share ONE dir that survives logout).
|
|
/// `PUNKTFUNK_CONFIG_DIR` overrides on both platforms (used by the Windows service config / tests).
|
|
pub fn config_dir() -> PathBuf {
|
|
if let Some(dir) = std::env::var_os("PUNKTFUNK_CONFIG_DIR").filter(|s| !s.is_empty()) {
|
|
return PathBuf::from(dir);
|
|
}
|
|
// Windows: %ProgramData% (e.g. C:\ProgramData\punktfunk) — machine-wide, SYSTEM-readable,
|
|
// persists across user logout, correct for a SYSTEM service. Falls back to %APPDATA% then CWD.
|
|
#[cfg(target_os = "windows")]
|
|
let base = std::env::var_os("ProgramData")
|
|
.or_else(|| std::env::var_os("APPDATA"))
|
|
.map(PathBuf::from)
|
|
.unwrap_or_else(|| PathBuf::from("."));
|
|
#[cfg(not(target_os = "windows"))]
|
|
let base = std::env::var_os("XDG_CONFIG_HOME")
|
|
.map(PathBuf::from)
|
|
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))
|
|
.unwrap_or_else(|| PathBuf::from("."));
|
|
base.join("punktfunk")
|
|
}
|
|
|
|
/// Create `dir` (and parents) owner-private — **0700** on Unix (so the host's secrets aren't readable
|
|
/// by other local users via a traversable config path). On Windows, applies a restrictive DACL
|
|
/// ([`restrict_dir_to_system_admins`]) so a local unprivileged user can't pre-create / plant files in
|
|
/// the config tree (the default `%ProgramData%` ACL grants Users *create*; security-review
|
|
/// 2026-06-28 #3/#11). Tightens (and re-owns) an already-existing dir too.
|
|
pub fn create_private_dir(dir: &std::path::Path) -> std::io::Result<()> {
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::{DirBuilderExt, PermissionsExt};
|
|
let r = std::fs::DirBuilder::new()
|
|
.recursive(true)
|
|
.mode(0o700)
|
|
.create(dir);
|
|
// `recursive` doesn't re-chmod an existing dir — tighten it so an old 0755 dir gets locked.
|
|
if dir.exists() {
|
|
let _ = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700));
|
|
}
|
|
r
|
|
}
|
|
#[cfg(not(unix))]
|
|
{
|
|
let r = std::fs::create_dir_all(dir);
|
|
#[cfg(windows)]
|
|
restrict_dir_to_system_admins(dir, first_hardening_of(dir));
|
|
r
|
|
}
|
|
}
|
|
|
|
/// Whether this is the first hardening pass of `dir` in this process — the pass that also does the
|
|
/// expensive recursive re-own.
|
|
///
|
|
/// A planted config dir is planted once, before the host ever starts, so one deep pass at startup
|
|
/// closes it; repeating it on every `create_private_dir` call (the library CRUD calls it per write)
|
|
/// would re-walk the whole config tree — recordings, art cache — for nothing.
|
|
#[cfg(windows)]
|
|
fn first_hardening_of(dir: &std::path::Path) -> bool {
|
|
use std::collections::HashSet;
|
|
use std::sync::{Mutex, OnceLock};
|
|
static SEEN: OnceLock<Mutex<HashSet<PathBuf>>> = OnceLock::new();
|
|
SEEN.get_or_init(|| Mutex::new(HashSet::new()))
|
|
.lock()
|
|
.map(|mut s| s.insert(dir.to_path_buf()))
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
/// Re-apply the secret-file DACL to a file that **already exists** — including re-owning it to
|
|
/// Administrators.
|
|
///
|
|
/// [`write_secret_file`] hardens what it writes, but a file that was planted before the host first
|
|
/// ran was never written by us: it is owned by whoever created it, and an owner always retains
|
|
/// `WRITE_DAC`, so re-ACLing without re-owning leaves them able to put their access straight back.
|
|
/// Used on startup for `host.env`, whose contents become the SYSTEM service's environment and
|
|
/// command line (2026-08-05 review H-4). Best-effort and never fatal.
|
|
#[cfg(windows)]
|
|
pub fn restrict_existing_secret_file(path: &std::path::Path) {
|
|
if !path.exists() {
|
|
return;
|
|
}
|
|
let icacls = icacls_path();
|
|
let _ = std::process::Command::new(&icacls)
|
|
.arg(path.as_os_str())
|
|
.args(["/setowner", "*S-1-5-32-544"]) // BUILTIN\Administrators
|
|
.stdout(std::process::Stdio::null())
|
|
.stderr(std::process::Stdio::null())
|
|
.status();
|
|
restrict_to_system_admins(path);
|
|
}
|
|
|
|
/// No-op off Windows: POSIX modes are set at creation by [`write_secret_file`] and a config dir a
|
|
/// non-root user pre-created is not a privilege boundary the way `%ProgramData%` is.
|
|
#[cfg(not(windows))]
|
|
pub fn restrict_existing_secret_file(_path: &std::path::Path) {}
|
|
|
|
/// `icacls` by absolute path — a privileged service must never resolve it through `PATH`.
|
|
#[cfg(windows)]
|
|
fn icacls_path() -> String {
|
|
std::env::var("SystemRoot")
|
|
.map(|r| format!("{r}\\System32\\icacls.exe"))
|
|
.unwrap_or_else(|_| "icacls".to_string())
|
|
}
|
|
|
|
/// Best-effort Windows DACL lockdown of the config *directory* (the companion to
|
|
/// [`restrict_to_system_admins`] for files). The default `%ProgramData%` ACL lets `BUILTIN\Users`
|
|
/// create subfolders/files (and become `CREATOR OWNER`), so a non-admin could pre-create the
|
|
/// `punktfunk` dir or plant a `host.env`/`apps.json` that the privileged SYSTEM service then trusts
|
|
/// (LPE; security-review 2026-06-28 #3). This re-owns the dir to Administrators (defeating a
|
|
/// pre-creation), strips inheritance, and sets an explicit DACL: SYSTEM/Administrators/OWNER full
|
|
/// (object+container inherit so child files/dirs inherit it), and Users **read-only** (so existing
|
|
/// reads of non-secret config keep working but a local user can no longer write/plant). Secret files
|
|
/// are additionally locked to SYSTEM/Admins by [`write_secret_file`]. Hard-coded SIDs
|
|
/// (locale-independent) via the absolute `%SystemRoot%` path; never fatal.
|
|
#[cfg(windows)]
|
|
fn restrict_dir_to_system_admins(dir: &std::path::Path, deep: bool) {
|
|
let icacls = icacls_path();
|
|
// Reset ownership to Administrators first, so a dir a non-admin may have pre-created can't keep
|
|
// OWNER control (an owner always retains WRITE_DAC and can put its access straight back).
|
|
//
|
|
// `deep` (once per directory per process — see `first_hardening_of`) also re-owns the CONTENTS.
|
|
// Re-owning only the directory left every file the attacker had already created still owned by
|
|
// them, and therefore still theirs to rewrite, which is half of why the 2026-08-05 review's H-4
|
|
// was exploitable end to end. A planted tree is planted once, before the host first runs, so one
|
|
// deep pass at startup closes it without re-walking recordings and art cache on every write.
|
|
let mut own = std::process::Command::new(&icacls);
|
|
own.arg(dir.as_os_str())
|
|
.args(["/setowner", "*S-1-5-32-544"]); // BUILTIN\Administrators
|
|
if deep {
|
|
own.args(["/T", "/C", "/Q"]); // recurse, continue on error, quiet
|
|
}
|
|
let _ = own
|
|
.stdout(std::process::Stdio::null())
|
|
.stderr(std::process::Stdio::null())
|
|
.status();
|
|
let status = std::process::Command::new(&icacls)
|
|
.arg(dir.as_os_str())
|
|
.args([
|
|
"/inheritance:r",
|
|
"/grant:r",
|
|
"*S-1-5-18:(OI)(CI)(F)", // NT AUTHORITY\SYSTEM
|
|
"/grant:r",
|
|
"*S-1-5-32-544:(OI)(CI)(F)", // BUILTIN\Administrators
|
|
// NO inheritable OWNER RIGHTS (`*S-1-3-4`) here, deliberately. It used to be granted
|
|
// `(OI)(CI)(F)`, which handed full control of every child object to whoever owned it —
|
|
// so a file a local user created before the hardening ran stayed writable by them even
|
|
// after the directory was re-owned (2026-08-05 review H-4, second half). SYSTEM and
|
|
// Administrators cover every account that legitimately writes here; a non-elevated
|
|
// manual run gets read-only config, which is the intended boundary rather than a
|
|
// regression — this directory drives command execution as SYSTEM.
|
|
"/grant:r",
|
|
"*S-1-5-32-545:(OI)(CI)(RX)", // BUILTIN\Users — read-only (no create/write → no plant)
|
|
])
|
|
.stdout(std::process::Stdio::null())
|
|
.stderr(std::process::Stdio::null())
|
|
.status();
|
|
match status {
|
|
Ok(s) if s.success() => {}
|
|
_ => tracing::warn!(
|
|
dir = %dir.display(),
|
|
"config-dir DACL hardening did not fully succeed — a local user may be able to plant config files"
|
|
),
|
|
}
|
|
}
|
|
|
|
/// Write `contents` to `path` as an **owner-only secret**: created and re-chmod'd **0600** on Unix
|
|
/// (never even briefly group/world-readable), and DACL-restricted to SYSTEM/Administrators/owner on
|
|
/// Windows (the default `%ProgramData%` ACL is Users-readable). Mirrors the mgmt-token hardening; used
|
|
/// for the host private key and the persisted trust stores so a local unprivileged user can neither
|
|
/// read the key (impersonation) nor tamper with the paired allow-list (unauthorized pairing).
|
|
///
|
|
/// **Windows ordering caveat** (2026-08-05 review L-17): this is create-then-`icacls`, not
|
|
/// create-with-DACL — `std::fs::OpenOptions` cannot pass a `SECURITY_ATTRIBUTES`, and this crate is
|
|
/// `#![forbid(unsafe_code)]` so it cannot call `CreateFileW` itself. The file therefore exists
|
|
/// briefly under its INHERITED ACL, and a failed `icacls` is a warning rather than an error.
|
|
///
|
|
/// What makes that acceptable is the DIRECTORY, and only the directory: every caller writes into
|
|
/// the config dir, which [`create_private_dir`] now hardens unconditionally and BEFORE the first
|
|
/// read of anything in it (review H-4/M-1), granting `BUILTIN\Users` read-only and no create. The
|
|
/// inherited ACL a secret is born with is therefore already SYSTEM/Administrators-only, and the
|
|
/// `icacls` below is defence in depth rather than the thing standing between a local user and the
|
|
/// host key. Keep that ordering — if the directory hardening is ever moved back after a read, this
|
|
/// window becomes real again.
|
|
pub fn write_secret_file(path: &std::path::Path, contents: &[u8]) -> std::io::Result<()> {
|
|
use std::io::Write;
|
|
let mut opts = std::fs::OpenOptions::new();
|
|
opts.write(true).create(true).truncate(true);
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::OpenOptionsExt;
|
|
opts.mode(0o600);
|
|
}
|
|
let mut f = opts.open(path)?;
|
|
f.write_all(contents)?;
|
|
f.flush()?;
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
|
|
}
|
|
#[cfg(windows)]
|
|
restrict_to_system_admins(path);
|
|
Ok(())
|
|
}
|
|
|
|
/// Best-effort Windows DACL lockdown of a secret file: strip inherited ACEs and grant Full only to
|
|
/// SYSTEM, Administrators, and OWNER RIGHTS (the creating account — the SYSTEM service or a manually
|
|
/// running user keeps access). Without this the host key under the default Users-readable
|
|
/// `%ProgramData%` ACL is readable by ANY local user. Uses `icacls` with hard-coded SIDs
|
|
/// (locale-independent) via the absolute `%SystemRoot%` path (a privileged service must not trust
|
|
/// `PATH`). Never fatal — on failure the file is simply left at the inherited ACL (today's behaviour).
|
|
#[cfg(windows)]
|
|
fn restrict_to_system_admins(path: &std::path::Path) {
|
|
let icacls = icacls_path();
|
|
let status = std::process::Command::new(icacls)
|
|
.arg(path.as_os_str())
|
|
.args([
|
|
"/inheritance:r",
|
|
"/grant:r",
|
|
"*S-1-5-18:(F)", // NT AUTHORITY\SYSTEM
|
|
"/grant:r",
|
|
"*S-1-5-32-544:(F)", // BUILTIN\Administrators
|
|
"/grant:r",
|
|
"*S-1-3-4:(F)", // OWNER RIGHTS
|
|
])
|
|
.stdout(std::process::Stdio::null())
|
|
.stderr(std::process::Stdio::null())
|
|
.status();
|
|
match status {
|
|
Ok(s) if s.success() => {}
|
|
_ => tracing::warn!(
|
|
path = %path.display(),
|
|
"icacls hardening did not succeed — this secret may be readable by other local users"
|
|
),
|
|
}
|
|
}
|