fix(security): the plugin lane stops being a way in
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.
This commit is contained in:
@@ -14,9 +14,19 @@ KERNEL=="uhid", SUBSYSTEM=="misc", OPTIONS+="static_node=uhid", GROUP="input", M
|
||||
# usbip vhci attach/detach for the virtual Steam Deck controller. Steam Input only
|
||||
# adopts the virtual Deck when it arrives as a USB device (usbip/vhci or raw_gadget);
|
||||
# the UHID fallback has no USB interface and Steam ignores it. The sysfs attach files
|
||||
# are root-only by default while the host runs as a user service — grant the `input`
|
||||
# group write when vhci_hcd appears (module autoload: modules-load.d/punktfunk.conf).
|
||||
ACTION=="add", SUBSYSTEM=="platform", KERNEL=="vhci_hcd.*", RUN+="/bin/sh -c 'chgrp input /sys%p/attach /sys%p/detach && chmod 0660 /sys%p/attach /sys%p/detach'"
|
||||
# are root-only by default while the host runs as a user service — grant the dedicated
|
||||
# `punktfunk` group write when vhci_hcd appears (module autoload: modules-load.d/punktfunk.conf).
|
||||
#
|
||||
# ⚠ This is deliberately NOT the `input` group (2026-08-05 review M-4). Writing `attach` hands the
|
||||
# kernel a caller-supplied socket fd and materialises an arbitrary, fully userspace-emulated USB
|
||||
# device — a root-only kernel primitive. Every packaging scriptlet tells the user to
|
||||
# `usermod -aG input $USER` as step 1, so putting it on `input` handed that primitive to a group
|
||||
# people are routinely told to join: a member could present a HID keyboard and inject keystrokes
|
||||
# into a root TTY or the lock screen, or drive any of hundreds of in-tree USB drivers from
|
||||
# userspace, all without CAP_SYS_ADMIN. The uinput/uhid grants above are already systemwide input
|
||||
# injection, but neither reaches kernel USB enumeration — this one does, so it gets its own group
|
||||
# that nothing else asks users to join.
|
||||
ACTION=="add", SUBSYSTEM=="platform", KERNEL=="vhci_hcd.*", RUN+="/bin/sh -c 'chgrp punktfunk /sys%p/attach /sys%p/detach && chmod 0660 /sys%p/attach /sys%p/detach'"
|
||||
|
||||
# hidraw access for the VIRTUAL pads this host creates. Steam/SDL drive a DualSense's rich
|
||||
# feedback (adaptive triggers, lightbar, player LEDs) exclusively over hidraw — the kernel has no
|
||||
|
||||
@@ -52,7 +52,19 @@ fi
|
||||
# it `kwin_wayland --virtual` brings up NO X server at all (no display reserved), and those apps die
|
||||
# with "Missing X Server or $DISPLAY". KWin starts Xwayland on demand but reserves + logs the X11
|
||||
# display up front, which the detection below reads.
|
||||
KWIN_LOG="${TMPDIR:-/tmp}/punktfunk-kwin.log"
|
||||
# The log lives in the per-user 0700 XDG_RUNTIME_DIR, not at a fixed name in a world-writable
|
||||
# /tmp. This file is not just a log: the DISPLAY detection below GREPS it for "Using public X11
|
||||
# display :N" and exports the result, so at a predictable path in a shared directory any local user
|
||||
# could pre-create it (or symlink it) and steer the DISPLAY of a shipped systemd service
|
||||
# (2026-08-05 review L-15). `pf-vdisplay` already resolves XDG_RUNTIME_DIR for its own paths; this
|
||||
# matches. Without a runtime dir, fall back to a private mktemp rather than a guessable name.
|
||||
if [[ -n "${XDG_RUNTIME_DIR:-}" && -d "${XDG_RUNTIME_DIR}" ]]; then
|
||||
KWIN_LOG="${XDG_RUNTIME_DIR}/punktfunk-kwin.log"
|
||||
: >"$KWIN_LOG"
|
||||
chmod 600 "$KWIN_LOG"
|
||||
else
|
||||
KWIN_LOG="$(mktemp -t punktfunk-kwin.XXXXXXXX.log)"
|
||||
fi
|
||||
kwin_wayland --virtual --xwayland --width "$W" --height "$H" --no-lockscreen \
|
||||
--socket "$WAYLAND_DISPLAY" >"$KWIN_LOG" 2>&1 &
|
||||
KWIN_PID=$!
|
||||
|
||||
@@ -11,7 +11,14 @@
|
||||
grant is scoped to the box's own local-seat session lifecycle — the same class of operation
|
||||
these distros already authorize for their session switcher (e.g. Nobara's
|
||||
os-session-select, allow_any). allow_any because the host commonly runs sessionless (a
|
||||
lingering user unit, no polkit agent), where interactive auth can never be answered. -->
|
||||
lingering user unit, no polkit agent), where interactive auth can never be answered.
|
||||
|
||||
⚠ These defaults authorize every local subject — including a seatless ssh session or a
|
||||
service account — so they are NOT the whole authorization story (2026-08-05 review L-14).
|
||||
They cannot be tightened without breaking the lingering-user-unit deployment, which polkit
|
||||
classifies under allow_any precisely because it has no session. The actual gate is in the
|
||||
helper: pf-dm-helper refuses any caller whose PKEXEC_UID is not in the `punktfunk` group.
|
||||
Keep the two in step — loosening the helper's check makes these defaults load-bearing. -->
|
||||
<action id="io.unom.punktfunk.dm-helper">
|
||||
<description>Stop or restore the display manager for a Punktfunk stream</description>
|
||||
<message>Authentication is required to switch the display manager for a Punktfunk stream</message>
|
||||
|
||||
+35
-7
@@ -13,6 +13,38 @@
|
||||
# local-seat operation, not arbitrary unit management.
|
||||
set -eu
|
||||
|
||||
# The polkit action has to stay permissive (`allow_any=yes`): the host commonly runs as a LINGERING
|
||||
# user unit, which has no logind session at all, so polkit classifies it under `allow_any` and any
|
||||
# stricter default would make the takeover unauthorizable in its primary deployment. The cost of
|
||||
# that is that polkit alone authorizes *every* local subject — a seatless ssh session, a service
|
||||
# account — to run this as root (2026-08-05 review L-14).
|
||||
#
|
||||
# So the authorization decision is made HERE instead, where the caller is knowable: pkexec sets
|
||||
# PKEXEC_UID from the authenticated caller, and only a member of the `punktfunk` group (created by
|
||||
# the packages) may proceed. That keeps the sessionless host working while making membership of one
|
||||
# explicit group — not merely "has a local uid" — the thing that grants these verbs.
|
||||
require_authorized_caller() {
|
||||
uid=${PKEXEC_UID:-}
|
||||
[ -n "$uid" ] || {
|
||||
echo "pf-dm-helper: no PKEXEC_UID in the environment — refusing to run unauthenticated" >&2
|
||||
exit 1
|
||||
}
|
||||
user=$(getent passwd "$uid" | cut -d: -f1) || user=
|
||||
[ -n "$user" ] || {
|
||||
echo "pf-dm-helper: PKEXEC_UID $uid resolves to no local user — refusing" >&2
|
||||
exit 1
|
||||
}
|
||||
# `id -nG` lists the primary group too, so a user whose primary group IS punktfunk also passes.
|
||||
for g in $(id -nG "$user" 2>/dev/null); do
|
||||
[ "$g" = punktfunk ] && return 0
|
||||
done
|
||||
echo "pf-dm-helper: user '$user' is not in the 'punktfunk' group — refusing." >&2
|
||||
echo " Grant it with: sudo usermod -aG punktfunk $user (then re-login)" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
require_authorized_caller
|
||||
|
||||
dm_unit() {
|
||||
target=$(readlink /etc/systemd/system/display-manager.service) || {
|
||||
echo "pf-dm-helper: no display-manager.service alias — no display manager to manage" >&2
|
||||
@@ -40,13 +72,9 @@ case "${1-}" in
|
||||
# what breaks that dependency (the setup docs already ask for it).
|
||||
#
|
||||
# The user is NEVER caller-named: PKEXEC_UID is set by pkexec from the authenticated caller,
|
||||
# so this grant enables lingering for that caller alone.
|
||||
uid=${PKEXEC_UID:-}
|
||||
[ -n "$uid" ] || {
|
||||
echo "pf-dm-helper: no PKEXEC_UID in the environment — refusing to guess a user" >&2
|
||||
exit 1
|
||||
}
|
||||
exec loginctl enable-linger "$uid"
|
||||
# so this grant enables lingering for that caller alone. (Its presence is already checked by
|
||||
# `require_authorized_caller` above, which also proved the caller is in the punktfunk group.)
|
||||
exec loginctl enable-linger "${PKEXEC_UID}"
|
||||
;;
|
||||
*)
|
||||
echo "usage: pf-dm-helper stop|restore|linger" >&2
|
||||
|
||||
@@ -185,6 +185,11 @@ ok "plugin runner: ~/.local/bin/punktfunk-scripting"
|
||||
# --- 3. config -------------------------------------------------------------
|
||||
log "Configuration ($CONFIG)"
|
||||
mkdir -p "$CONFIG"
|
||||
# Owner-only: this directory holds web.env (console password + session secret), the mgmt token and
|
||||
# the host key. A plain `mkdir -p` leaves it 0755 at the Deck's default umask, so the secrets below
|
||||
# sat in a world-TRAVERSABLE directory (2026-08-05 review L-19). Matches what the host itself does
|
||||
# via `pf_paths::create_private_dir`, and is idempotent on an existing dir.
|
||||
chmod 700 "$CONFIG" 2>/dev/null || true
|
||||
if [ ! -f "$CONFIG/host.env" ]; then
|
||||
cat > "$CONFIG/host.env" <<'EOF'
|
||||
# punktfunk Steam Deck host config (sourced by the punktfunk-host user service).
|
||||
@@ -235,10 +240,16 @@ if [ "$WITH_WEB" = 1 ] && [ ! -f "$CONFIG/web.env" ]; then
|
||||
# `|| true` swallows the SIGPIPE `tr` takes when `head` closes the pipe (pipefail would abort).
|
||||
WEB_PW="$(LC_ALL=C tr -dc 'a-z0-9' </dev/urandom 2>/dev/null | head -c 12 || true)"
|
||||
WEB_SECRET="$(LC_ALL=C tr -dc 'A-Za-z0-9' </dev/urandom 2>/dev/null | head -c 32 || true)"
|
||||
cat > "$CONFIG/web.env" <<EOF
|
||||
# `umask 077` around the redirect, not `chmod 600` after it: the heredoc CREATES the file at
|
||||
# the ambient umask (0022 on a Deck ⇒ world-readable), so the console password and session
|
||||
# secret existed group/world-readable for the window between the redirect and the chmod
|
||||
# (2026-08-05 review L-19). Setting the mask first means the file is never readable at all.
|
||||
# The chmod stays as the idempotent belt for a pre-existing file.
|
||||
(umask 077; cat > "$CONFIG/web.env" <<EOF
|
||||
PUNKTFUNK_UI_PASSWORD=$WEB_PW
|
||||
PUNKTFUNK_UI_SECRET=$WEB_SECRET
|
||||
EOF
|
||||
)
|
||||
chmod 600 "$CONFIG/web.env"
|
||||
ok "wrote web.env (generated login password)"
|
||||
else
|
||||
|
||||
+9
-2
@@ -14,6 +14,13 @@ if [ ! -s "$PWFILE" ]; then
|
||||
PW=$(head -c 18 /dev/urandom | base64 | tr -d '/+=' | cut -c1-20)
|
||||
(umask 077; printf 'PUNKTFUNK_UI_PASSWORD=%s\n' "$PW" > "$PWFILE")
|
||||
chmod 600 "$PWFILE" 2>/dev/null || true
|
||||
echo "punktfunk web console login password generated: $PW"
|
||||
echo "(stored in $PWFILE — open https://<host-ip>:47992 and log in)"
|
||||
# Do NOT echo the password itself. Anything this script prints is captured by systemd into
|
||||
# the PERSISTENT journal, which on Debian/Ubuntu is readable by the `adm` and
|
||||
# `systemd-journal` groups — so printing it published a 0600 secret to every member of them,
|
||||
# permanently, and the .deb postinst then documented `journalctl` as the way to read it
|
||||
# (2026-08-05 review L-18). Point at the file instead: it is the same one command, it is
|
||||
# correctly 0600, and it stays readable only by the user who owns the console.
|
||||
echo "punktfunk web console login password generated."
|
||||
echo "Read it with: cut -d= -f2- $PWFILE"
|
||||
echo "(then open https://<host-ip>:47992 and log in)"
|
||||
fi
|
||||
|
||||
Reference in New Issue
Block a user