* Don't put the user in the docker group; make it opt-in The docker group is root-equivalent: anything in it can `docker run -v /:/host` and rewrite the host as root with no password. On a single-user box that's not an escalation (the owner is already a wheel/sudo user), but it hands any code running as the user — a rogue plugin, a poisoned dependency — a silent, headless, passwordless path to root that sudo's password prompt would otherwise gate. Stop granting the docker group by default. The daemon still runs (docker.socket); the Docker TUI and the Windows VM reach it through a polkit prompt, and the plain `docker` CLI runs under sudo. Sudoless Docker is a warned opt-in via Setup > Security (omarchy-setup-security-sudoless-docker). No automatic path may re-grant it: install and first-boot provisioning never record or apply the group (provisioning also filters a docker line left in an older factory snapshot), and the Quattro upgrade no longer adds it. The Windows VM keeps needing the root daemon for a privileged container (KVM, NET_ADMIN), so it is reworked to run without the group and without becoming a new way in: - The compose lives in a root-owned dir and is only written by an elevated, input-validated writer. A root-invoked bring-up must never consume a file a user-process could rewrite to bind-mount / into the guest — the old ~/.config/windows compose was exactly that. Volume paths are rebuilt from $HOME on migration rather than trusted from the (user-writable) legacy file, path validation rejects traversal, and the privileged sub-action is checked against an allowlist before dispatch (a slash in it would otherwise run as a path). - pkexec elevates a verified root-owned command path, not a PATH-resolved one, so an authorized prompt can't be redirected to an attacker's binary. - The guest password is kept in a private 0600 per-user file for RDP instead of a world-readable compose, and a declined authorization is reported as such, never as a completed stop. Existing installs auto-migrate the VM (no redownload) and refresh the stale Docker launcher entry. 🤖 Generated by Opus 4.8 in Claude Code. Reviewed by Codex XHigh. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-Authored-By: Codex XHigh <codex@openai.com> Claude-Session: https://claude.ai/code/session_01Gb7x6poap4hGCndPx5qt5T * Migrate existing installs off the docker group The default flip only reaches new installs; existing users keep their docker group membership and stay exposed. Extend the migration that already refreshes the Docker launcher to also remove the current user from the group when present, reusing omarchy-remove-security-sudoless-docker so there is one source of truth for the change and its notice. It takes effect at next login (the current session keeps working), and passwordless docker can be turned back on from Setup > Security > Sudoless Docker. Migrations run with sudo available — during `omarchy update`, or in the terminal the pending-migrations notification opens — so the privileged removal does not prompt at an unattended login. The no-op path (already out of the group) needs no privilege. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Gb7x6poap4hGCndPx5qt5T * Refuse symlinked VM mount sources; correct the docker CLI docs Review follow-ups. valid_path keeps a traversal string (/./, //, ..) out of the compose, but it is a string check: a symlink planted at ~/.windows or ~/Windows redirects the privileged bind mount exactly as traversal would, because docker follows it. So verify the mount sources as root immediately before bringing the VM up — refuse a source that is a symlink or resolves through one — which is where the string check cannot help. A missing source stays fine (docker creates a plain dir). Also correct the development-tools manual: the CLI is not transparently elevated (there is no docker wrapper and `d` is still plain docker), so say plainly that docker on the command line takes `sudo` until sudoless Docker is enabled. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Gb7x6poap4hGCndPx5qt5T --------- Co-authored-by: David Heinemeier Hansson <david@hey.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Codex XHigh <codex@openai.com>
125 lines
5.9 KiB
Bash
125 lines
5.9 KiB
Bash
#!/bin/bash
|
|
#
|
|
# The Windows VM compose file is written by an elevated, input-validated writer
|
|
# into a root-owned directory. These tests pin the security-critical behavior:
|
|
# no input can inject a host-root bind mount or a privileged flag, the password
|
|
# survives both the YAML and the compose-interpolation layer, only known
|
|
# privileged actions dispatch, and legacy configs migrate without redownloading.
|
|
|
|
set -euo pipefail
|
|
|
|
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
|
|
|
TMPDIR=$(mktemp -d)
|
|
trap 'rm -rf "$TMPDIR"' EXIT
|
|
export OMARCHY_WINDOWS_DIR="$TMPDIR/win"
|
|
|
|
# Source the command's functions; the dispatcher just prints usage for "help".
|
|
set -- help
|
|
source "$ROOT/bin/omarchy-windows-vm" >/dev/null 2>&1
|
|
COMPOSE="$OMARCHY_WINDOWS_DIR/docker-compose.yml"
|
|
|
|
write() { # RAM CORES DISK USER PASS TZ STORAGE SHARED
|
|
printf 'RAM=%s\nCORES=%s\nDISK=%s\nUSERNAME=%s\nPASSWORD=%s\nTZ=%s\nSTORAGE=%s\nSHARED=%s\n' \
|
|
"$@" | __priv_write_compose
|
|
}
|
|
|
|
# --- valid compose, with the dangerous bits pinned and unreachable by input ---
|
|
rm -f "$COMPOSE"
|
|
write 4G 2 64G alice 's3cret' Europe/Copenhagen /home/alice/.windows /home/alice/Windows
|
|
[[ -f $COMPOSE ]] || fail "writer produced a compose file"
|
|
grep -q 'image: dockurr/windows' "$COMPOSE" || fail "image is pinned"
|
|
grep -q -- '- NET_ADMIN' "$COMPOSE" || fail "cap_add is pinned"
|
|
grep -q -- '- /home/alice/.windows:/storage' "$COMPOSE" || fail "storage volume uses the given path"
|
|
grep -q -- '- /:/' "$COMPOSE" && fail "compose must never contain a host-root bind mount"
|
|
pass "writer emits a pinned compose with no host-root mount"
|
|
|
|
# --- injection attempts are rejected, no file written ---
|
|
rm -f "$COMPOSE"
|
|
write 4G 2 64G 'x -v /:/h' p UTC /a /b 2>/dev/null && fail "malicious username was accepted"
|
|
[[ ! -f $COMPOSE ]] || fail "no compose written for a bad username"
|
|
write 4G 2 64G ok p UTC '/a -v /etc:/etc' /b 2>/dev/null && fail "malicious storage path was accepted"
|
|
write '4G; rm -rf /' 2 64G ok p UTC /a /b 2>/dev/null && fail "malicious RAM was accepted"
|
|
pass "injection attempts in username, path, and RAM are rejected"
|
|
|
|
# --- password survives YAML (" \) and compose interpolation ($) ---
|
|
rm -f "$COMPOSE"
|
|
tricky='p@$$w:rd$HOME"x\y'
|
|
write 8G 4 64G bob "$tricky" UTC /h/.windows /h/Windows
|
|
grep -q 'PASSWORD: ".*\$\$.*"' "$COMPOSE" || fail "\$ is escaped as \$\$ for compose interpolation"
|
|
recovered=$(unescape "$(read_compose_value PASSWORD "$COMPOSE")")
|
|
[[ $recovered == "$tricky" ]] || fail "password round-trips through write/unescape"
|
|
pass "password with \" \\ and \$ round-trips"
|
|
|
|
# --- only known privileged actions may dispatch ---
|
|
for action in write_compose up up_wait down status remove; do
|
|
valid_priv_action "$action" || fail "known privileged action rejected: $action"
|
|
done
|
|
for action in '/../evil/x' bogus 'up;rm' '' '__priv_up'; do
|
|
valid_priv_action "$action" && fail "privileged action whitelist accepted: [$action]"
|
|
done
|
|
pass "privileged action whitelist accepts known actions and rejects the rest"
|
|
|
|
# --- legacy per-user compose migrates into the root-owned location ---
|
|
# A rogue process could have rewritten the user-owned legacy compose to bind
|
|
# mount host / into the guest, so migration must ignore its volume paths and
|
|
# reconstruct them from the current user's $HOME.
|
|
rm -rf "$OMARCHY_WINDOWS_DIR"
|
|
export HOME="$TMPDIR/home"
|
|
mkdir -p "$HOME/.config/windows"
|
|
LEGACY_COMPOSE_FILE="$HOME/.config/windows/docker-compose.yml"
|
|
COMPOSE_FILE="$COMPOSE"
|
|
cat >"$LEGACY_COMPOSE_FILE" <<'LEG'
|
|
services:
|
|
windows:
|
|
environment:
|
|
RAM_SIZE: "16G"
|
|
CPU_CORES: "6"
|
|
DISK_SIZE: "128G"
|
|
USERNAME: "legacyuser"
|
|
PASSWORD: "legacypass"
|
|
TZ: "America/New_York"
|
|
volumes:
|
|
- /./:/storage
|
|
- /etc:/shared
|
|
LEG
|
|
# In production the write elevates via pkexec; here run it in-process.
|
|
priv() { local a=$1; shift; "__priv_$a" "$@"; }
|
|
migrate_legacy_compose
|
|
[[ -f $COMPOSE_FILE ]] || fail "migration wrote the root-owned compose"
|
|
grep -q 'USERNAME: "legacyuser"' "$COMPOSE_FILE" || fail "migration preserves settings"
|
|
grep -q -- "- $HOME/.windows:/storage" "$COMPOSE_FILE" || fail "migration uses the user's home for the data volume"
|
|
grep -q -- '- /:/' "$COMPOSE_FILE" && fail "migration must not carry a host-root bind mount from a tampered legacy file"
|
|
grep -q -- '- /etc:/shared' "$COMPOSE_FILE" && fail "migration must not carry a tampered legacy volume path"
|
|
[[ ! -f $LEGACY_COMPOSE_FILE ]] || fail "migration removes the legacy compose"
|
|
pass "migration reconstructs data paths from \$HOME and ignores tampered legacy volumes"
|
|
|
|
# --- bring-up refuses a symlinked mount source (a symlink redirects the
|
|
# privileged bind mount the same way traversal would; the string check on
|
|
# the stored path cannot see it) ---
|
|
rm -f "$COMPOSE"
|
|
mkdir -p "$TMPDIR/realstore" "$TMPDIR/realshare"
|
|
write 4G 2 64G dave pw UTC "$TMPDIR/realstore" "$TMPDIR/realshare"
|
|
assert_mounts_safe || fail "real directory mount sources are accepted"
|
|
ln -sfn / "$TMPDIR/evilshare"
|
|
write 4G 2 64G dave pw UTC "$TMPDIR/realstore" "$TMPDIR/evilshare"
|
|
assert_mounts_safe && fail "a symlinked mount source must be refused"
|
|
pass "bring-up refuses a symlinked mount source"
|
|
|
|
# --- valid_path rejects traversal and non-normalized paths ---
|
|
for p in /home/u/.windows /var/lib/omarchy/windows; do
|
|
valid_path "$p" || fail "valid_path rejected a normal path: $p"
|
|
done
|
|
for p in / /./ // /tmp/../etc /home/u/. '/home/u/../root' '/a//b'; do
|
|
valid_path "$p" && fail "valid_path accepted a traversal/non-normalized path: $p"
|
|
done
|
|
pass "valid_path accepts normalized paths and rejects traversal"
|
|
|
|
# --- credentials are stored privately and round-trip (incl. = in password) ---
|
|
export CREDENTIALS_FILE="$TMPDIR/creds"
|
|
write_credentials 'carol' 'p=a$$w"x'
|
|
[[ $(stat -c '%a' "$CREDENTIALS_FILE") == "600" ]] || fail "credentials file is 0600"
|
|
[[ $(read_credential USERNAME) == "carol" ]] || fail "username round-trips"
|
|
[[ $(read_credential PASSWORD) == 'p=a$$w"x' ]] || fail "password (with =) round-trips"
|
|
pass "credentials are written 0600 and round-trip"
|