[Security] Pin Windows VM mounts behind a root boundary

This commit is contained in:
Afonso Oliveira
2026-08-25 18:39:00 +01:00
parent 9301092404
commit c34d20ca14
3 changed files with 343 additions and 103 deletions
+290 -70
View File
@@ -67,9 +67,14 @@ priv_target() {
priv() { priv() {
local action="$1" local action="$1"
shift shift
if [[ $action != write_compose ]] && ! docker_needs_sudo; then if [[ $action != write_compose && $action != remove ]] && ! docker_needs_sudo; then
"__priv_$action" "$@" # Existing installs used bind sources in $HOME. One privileged run is
return # needed to atomically move those directories below the root-owned mount
# boundary, even when the caller can otherwise reach Docker directly.
if [[ $action != up && $action != up_wait ]] || ! compose_needs_mount_migration; then
"__priv_$action" "$@"
return
fi
fi fi
local target local target
target=$(priv_target) || { target=$(priv_target) || {
@@ -88,16 +93,6 @@ valid_cores() { [[ $1 =~ ^[0-9]{1,2}$ ]] && ((10#$1 >= 1)); }
valid_disk() { [[ $1 =~ ^[0-9]{1,4}G$ ]]; } valid_disk() { [[ $1 =~ ^[0-9]{1,4}G$ ]]; }
valid_username() { [[ $1 =~ ^[A-Za-z0-9_-]{1,20}$ ]]; } valid_username() { [[ $1 =~ ^[A-Za-z0-9_-]{1,20}$ ]]; }
valid_tz() { [[ $1 =~ ^[A-Za-z0-9_/.+-]{1,64}$ ]]; } valid_tz() { [[ $1 =~ ^[A-Za-z0-9_/.+-]{1,64}$ ]]; }
valid_path() {
[[ $1 =~ ^/[A-Za-z0-9._/-]+$ ]] || return 1
# Reject non-normalized paths: a . or .. component canonicalizes at mount time
# (e.g. /./ or /a/../ -> /), which would bind-mount a sensitive directory —
# host / included — into the guest. Volumes must be given already-normalized.
case "$1" in
*//* | */./* | */../* | */. | */..) return 1 ;;
esac
return 0
}
valid_password() { [[ $1 =~ ^[[:print:]]{1,64}$ ]]; } valid_password() { [[ $1 =~ ^[[:print:]]{1,64}$ ]]; }
# The only privileged sub-actions __priv may dispatch. A bash command name # The only privileged sub-actions __priv may dispatch. A bash command name
@@ -113,13 +108,207 @@ valid_priv_action() {
# --- privileged actions (run as root via pkexec, or directly when sudoless) --- # --- privileged actions (run as root via pkexec, or directly when sudoless) ---
# Resolve the account that authorized pkexec. Never trust HOME or a caller-
# supplied mount path in the privileged process: pkexec can reset HOME, and the
# old path arguments were the source of an arbitrary host bind-mount primitive.
resolve_caller() {
local entry canonical parent owner mode
if ((EUID == 0)); then
[[ ${PKEXEC_UID:-} =~ ^[0-9]+$ ]] && ((10#$PKEXEC_UID > 0)) || {
echo "omarchy-windows-vm: cannot identify the user who authorized this action" >&2
return 1
}
CALLER_UID=$((10#$PKEXEC_UID))
else
CALLER_UID=$(id -u)
fi
entry=$(getent passwd "$CALLER_UID") || {
echo "omarchy-windows-vm: no account exists for uid $CALLER_UID" >&2
return 1
}
IFS=: read -r _ _ _ CALLER_GID _ CALLER_HOME _ <<<"$entry"
[[ $CALLER_GID =~ ^[0-9]+$ && $CALLER_HOME == /* && -d $CALLER_HOME ]] || {
echo "omarchy-windows-vm: invalid home directory for uid $CALLER_UID" >&2
return 1
}
# A direct, non-root development invocation with a non-standard runtime has
# no privilege boundary and may use its current HOME (which also keeps these
# functions testable). Production always uses the account database value.
if ((EUID != 0)) && [[ $RUNTIME_DIR != /var/lib/omarchy/windows ]]; then
CALLER_HOME=${HOME:-$CALLER_HOME}
fi
canonical=$(realpath -e -- "$CALLER_HOME" 2>/dev/null) || return 1
[[ $canonical == "$CALLER_HOME" ]] || {
echo "omarchy-windows-vm: refusing a home directory reached through a symlink" >&2
return 1
}
if ((EUID == 0)); then
owner=$(stat -Lc '%u' "$CALLER_HOME") || return 1
[[ $owner == "$CALLER_UID" ]] || {
echo "omarchy-windows-vm: caller does not own $CALLER_HOME" >&2
return 1
}
# The user must not be able to rename or replace their home while root is
# moving the legacy data entry out of it.
parent=$(dirname -- "$CALLER_HOME")
while :; do
owner=$(stat -Lc '%u' "$parent") || return 1
mode=$(stat -Lc '%a' "$parent") || return 1
[[ $owner == 0 ]] && ! ((8#$mode & 022)) || {
echo "omarchy-windows-vm: unsafe writable parent in home path: $parent" >&2
return 1
}
[[ $parent == / ]] && break
parent=$(dirname -- "$parent")
done
fi
# Keep the potentially large disk on the same filesystem as the user's home,
# but outside that user-writable directory. The already-validated home parent
# is root-owned, so this sibling tree provides a stable rename boundary.
MOUNT_ROOT="$(dirname -- "$CALLER_HOME")/.omarchy-windows"
USERS_DIR="$MOUNT_ROOT/users"
CALLER_DATA_ROOT="$USERS_DIR/$CALLER_UID"
EXPECTED_STORAGE="$CALLER_DATA_ROOT/storage"
EXPECTED_SHARED="$CALLER_DATA_ROOT/shared"
LEGACY_STORAGE="$CALLER_HOME/.windows"
LEGACY_SHARED="$CALLER_HOME/Windows"
}
boundary_owner() {
# Production boundaries remain root-owned even when a docker-group user runs
# the read-only bring-up checks directly. A non-standard runtime is supported
# only for unprivileged tests/development and is owned by that caller.
if ((EUID == 0)) || [[ $RUNTIME_DIR == /var/lib/omarchy/windows ]]; then
printf '0'
else
printf '%s' "$CALLER_UID"
fi
}
assert_boundary_dir() {
local path="$1" expected_owner="$2" owner mode canonical
[[ -d $path && ! -L $path ]] || return 1
canonical=$(realpath -e -- "$path" 2>/dev/null) || return 1
[[ $canonical == "$path" ]] || return 1
owner=$(stat -Lc '%u' "$path") || return 1
mode=$(stat -Lc '%a' "$path") || return 1
[[ $owner == "$expected_owner" ]] && ! ((8#$mode & 022))
}
prepare_runtime_tree() {
local owner probe
owner=$(boundary_owner)
if ((EUID == 0)); then
[[ $RUNTIME_DIR == /var/lib/omarchy/windows ]] || {
echo "omarchy-windows-vm: refusing a non-standard privileged runtime path" >&2
return 1
}
# Check the nearest existing ancestor before mkdir can follow anything.
# Every new component is then created by root and checked again below.
probe=$RUNTIME_DIR
while [[ ! -e $probe && ! -L $probe ]]; do probe=$(dirname -- "$probe"); done
while :; do
assert_boundary_dir "$probe" 0 || {
echo "omarchy-windows-vm: unsafe runtime parent: $probe" >&2
return 1
}
[[ $probe == / ]] && break
probe=$(dirname -- "$probe")
done
if [[ -e $MOUNT_ROOT || -L $MOUNT_ROOT ]]; then
assert_boundary_dir "$MOUNT_ROOT" 0 || {
echo "omarchy-windows-vm: unsafe mount root: $MOUNT_ROOT" >&2
return 1
}
fi
fi
mkdir -p -- "$RUNTIME_DIR" "$MOUNT_ROOT" "$USERS_DIR" "$CALLER_DATA_ROOT"
chmod 0755 "$RUNTIME_DIR" "$MOUNT_ROOT" "$USERS_DIR" "$CALLER_DATA_ROOT"
if ((EUID == 0)); then
chown root:root "$RUNTIME_DIR" "$MOUNT_ROOT" "$USERS_DIR" "$CALLER_DATA_ROOT"
fi
assert_boundary_dir "$RUNTIME_DIR" "$owner" &&
assert_boundary_dir "$MOUNT_ROOT" "$owner" &&
assert_boundary_dir "$USERS_DIR" "$owner" &&
assert_boundary_dir "$CALLER_DATA_ROOT" "$owner" || {
echo "omarchy-windows-vm: unsafe VM mount boundary" >&2
return 1
}
}
# Move an existing home entry first, then inspect the pinned object below the
# root-owned parent. This closes the check/use gap where an attacker could swap
# a checked home directory for a symlink before Docker resolved it.
prepare_mount_leaf() {
local legacy="$1" stable="$2" rejected source_dev target_dev
if [[ ! -e $stable && ! -L $stable ]]; then
if [[ -e $legacy || -L $legacy ]]; then
# rename(2) pins the exact directory entry the caller presented. GNU mv
# falls back to a privileged recursive copy across filesystems, which
# would reopen the source path and reintroduce the race, so fail closed in
# that uncommon layout instead of copying as root.
source_dev=$(stat -c '%d' -- "$legacy") || return 1
target_dev=$(stat -Lc '%d' -- "$CALLER_DATA_ROOT") || return 1
[[ $source_dev == "$target_dev" ]] || {
echo "omarchy-windows-vm: cannot safely migrate $legacy across filesystems" >&2
echo "Move it onto the filesystem containing $MOUNT_ROOT, then retry." >&2
return 1
}
mv --no-copy -T -- "$legacy" "$stable" || return 1
if [[ ! -d $stable || -L $stable ]]; then
rejected="$CALLER_DATA_ROOT/rejected-$(basename -- "$stable")-$$"
mv --no-copy -T -- "$stable" "$rejected" 2>/dev/null || true
echo "omarchy-windows-vm: refusing non-directory VM data entry at $legacy" >&2
return 1
fi
else
install -d -m 0700 "$stable"
fi
fi
[[ -d $stable && ! -L $stable ]] && [[ $(realpath -e -- "$stable" 2>/dev/null) == "$stable" ]] || {
echo "omarchy-windows-vm: unsafe VM data directory: $stable" >&2
return 1
}
if ((EUID == 0)); then
chown "$CALLER_UID:$CALLER_GID" "$stable"
fi
if [[ -L $legacy ]]; then
[[ $(realpath -e -- "$legacy" 2>/dev/null) == "$stable" ]] || {
echo "omarchy-windows-vm: $legacy does not point to its protected mount anchor" >&2
return 1
}
elif [[ -e $legacy ]]; then
echo "omarchy-windows-vm: refusing to replace existing data at $legacy" >&2
return 1
else
ln -s -- "$stable" "$legacy" || return 1
if ((EUID == 0)); then
chown -h "$CALLER_UID:$CALLER_GID" "$legacy"
fi
fi
}
prepare_caller_mounts() {
resolve_caller && prepare_runtime_tree &&
prepare_mount_leaf "$LEGACY_STORAGE" "$EXPECTED_STORAGE" &&
prepare_mount_leaf "$LEGACY_SHARED" "$EXPECTED_SHARED"
}
# Reads KEY=VALUE lines on stdin, re-validates every field, and writes the # Reads KEY=VALUE lines on stdin, re-validates every field, and writes the
# compose atomically as root. Re-validation here is the security boundary: the # compose atomically as root. Re-validation here is the security boundary: the
# writer refuses rather than emit a compose an attacker could have influenced. # writer refuses rather than emit a compose an attacker could have influenced.
# Only these fixed keys are honored; image, container name, devices, caps, and # Only these fixed keys are honored; image, container name, devices, caps, and
# port bindings are hard-coded and never taken from input. # port bindings are hard-coded and never taken from input.
__priv_write_compose() { __priv_write_compose() {
local ram cores disk username password tz storage shared key value local ram cores disk username password tz key value
while IFS='=' read -r key value; do while IFS='=' read -r key value; do
case "$key" in case "$key" in
@@ -129,8 +318,6 @@ __priv_write_compose() {
USERNAME) username="$value" ;; USERNAME) username="$value" ;;
PASSWORD) password="$value" ;; PASSWORD) password="$value" ;;
TZ) tz="$value" ;; TZ) tz="$value" ;;
STORAGE) storage="$value" ;;
SHARED) shared="$value" ;;
esac esac
done done
@@ -140,8 +327,7 @@ __priv_write_compose() {
valid_username "$username" || { echo "invalid username: $username" >&2; exit 2; } valid_username "$username" || { echo "invalid username: $username" >&2; exit 2; }
valid_password "$password" || { echo "invalid password" >&2; exit 2; } valid_password "$password" || { echo "invalid password" >&2; exit 2; }
valid_tz "$tz" || tz="UTC" valid_tz "$tz" || tz="UTC"
valid_path "$storage" || { echo "invalid storage path: $storage" >&2; exit 2; } prepare_caller_mounts || exit 2
valid_path "$shared" || { echo "invalid shared path: $shared" >&2; exit 2; }
# Neutralize anything in the password that could be misread when the compose # Neutralize anything in the password that could be misread when the compose
# is parsed. Two layers apply, in this order at parse time: docker compose # is parsed. Two layers apply, in this order at parse time: docker compose
@@ -154,10 +340,6 @@ __priv_write_compose() {
esc_password=${esc_password//\"/\\\"} esc_password=${esc_password//\"/\\\"}
esc_password=${esc_password//\$/\$\$} esc_password=${esc_password//\$/\$\$}
mkdir -p "$RUNTIME_DIR"
chmod 0755 "$RUNTIME_DIR" 2>/dev/null || true
chown root:root "$RUNTIME_DIR" 2>/dev/null || true
local tmp local tmp
tmp=$(mktemp "$RUNTIME_DIR/.compose.XXXXXX") tmp=$(mktemp "$RUNTIME_DIR/.compose.XXXXXX")
cat >"$tmp" <<EOF cat >"$tmp" <<EOF
@@ -184,8 +366,8 @@ services:
- 127.0.0.1:3389:3389/tcp - 127.0.0.1:3389:3389/tcp
- 127.0.0.1:3389:3389/udp - 127.0.0.1:3389:3389/udp
volumes: volumes:
- $storage:/storage - $EXPECTED_STORAGE:/storage
- $shared:/shared - $EXPECTED_SHARED:/shared
restart: "no" restart: "no"
stop_grace_period: 2m stop_grace_period: 2m
EOF EOF
@@ -200,41 +382,81 @@ EOF
} }
# Read the host source of a bind mount out of the compose (e.g. /storage). # Read the host source of a bind mount out of the compose (e.g. /storage).
# valid_path kept a ':' out of the stored path, so splitting on it is safe.
get_mount_source() { get_mount_source() {
sed -n "s|^[[:space:]]*-[[:space:]]*\(/[^:]*\):$1\$|\1|p" "$COMPOSE_FILE" | head -n1 sed -n "s|^[[:space:]]*-[[:space:]]*\(/[^:]*\):$1\$|\1|p" "$COMPOSE_FILE" | head -n1
} }
# Refuse to bring the VM up if a bind-mount source is a symlink, or reached # True only for the exact pair emitted by older Omarchy releases. The result is
# through one. valid_path keeps a traversal string like /./ out of the compose, # used by priv() to force a one-time elevated migration for sudoless-Docker
# but a symlink planted at ~/.windows or ~/Windows would redirect the privileged # users; arbitrary or mixed bind sources are never classified as migratable.
# mount just the same — docker follows it — and a string check cannot see that. compose_needs_mount_migration() {
# So verify the real directories here, as root, immediately before the mount. A [[ -f $COMPOSE_FILE ]] || return 1
# source that does not exist is fine: docker creates it as a plain directory. resolve_caller || return 1
[[ $(get_mount_source /storage) == "$LEGACY_STORAGE" &&
$(get_mount_source /shared) == "$LEGACY_SHARED" ]]
}
rewrite_compose_mounts() {
local tmp
tmp=$(mktemp "$RUNTIME_DIR/.compose.XXXXXX") || return 1
awk -v storage="$EXPECTED_STORAGE" -v shared="$EXPECTED_SHARED" '
/^[[:space:]]*-[[:space:]]*\/[^:]*:\/storage$/ { print " - " storage ":/storage"; next }
/^[[:space:]]*-[[:space:]]*\/[^:]*:\/shared$/ { print " - " shared ":/shared"; next }
{ print }
' "$COMPOSE_FILE" >"$tmp" || { rm -f "$tmp"; return 1; }
chmod 0640 "$tmp"
if ((EUID == 0)); then
chown root:docker "$tmp" 2>/dev/null || chown root:root "$tmp"
fi
mv -f -- "$tmp" "$COMPOSE_FILE"
}
assert_compose_trusted() {
local owner expected mode
[[ -f $COMPOSE_FILE && ! -L $COMPOSE_FILE ]] || return 1
owner=$(stat -Lc '%u' "$COMPOSE_FILE") || return 1
mode=$(stat -Lc '%a' "$COMPOSE_FILE") || return 1
expected=$(boundary_owner)
[[ $owner == "$expected" ]] && ! ((8#$mode & 022))
}
assert_mounts_safe() { assert_mounts_safe() {
local mnt src real local storage shared owner
for mnt in /storage /shared; do resolve_caller || return 1
src=$(get_mount_source "$mnt") assert_compose_trusted || {
[[ -n $src ]] || { echo "omarchy-windows-vm: refusing an untrusted compose file" >&2
echo "omarchy-windows-vm: missing $mnt mount source in the compose" >&2 return 1
}
storage=$(get_mount_source /storage)
shared=$(get_mount_source /shared)
if [[ $storage == "$LEGACY_STORAGE" && $shared == "$LEGACY_SHARED" ]]; then
((EUID == 0)) || {
echo "omarchy-windows-vm: legacy VM data needs an authorized migration" >&2
return 1 return 1
} }
if [[ -L $src ]]; then prepare_caller_mounts || return 1
echo "omarchy-windows-vm: refusing to start — $src is a symlink; the VM mount source must be a real directory" >&2 rewrite_compose_mounts || return 1
return 1 storage=$EXPECTED_STORAGE
fi shared=$EXPECTED_SHARED
if [[ -e $src ]]; then fi
[[ -d $src ]] || {
echo "omarchy-windows-vm: refusing to start — $src is not a directory" >&2 [[ $storage == "$EXPECTED_STORAGE" && $shared == "$EXPECTED_SHARED" ]] || {
return 1 echo "omarchy-windows-vm: refusing unexpected host paths in the compose" >&2
} return 1
real=$(realpath "$src" 2>/dev/null) }
[[ $real == "$src" ]] || { owner=$(boundary_owner)
echo "omarchy-windows-vm: refusing to start — $src resolves through a symlink to $real" >&2 assert_boundary_dir "$RUNTIME_DIR" "$owner" &&
return 1 assert_boundary_dir "$MOUNT_ROOT" "$owner" &&
} assert_boundary_dir "$USERS_DIR" "$owner" &&
fi assert_boundary_dir "$CALLER_DATA_ROOT" "$owner" &&
done [[ -d $EXPECTED_STORAGE && ! -L $EXPECTED_STORAGE ]] &&
[[ -d $EXPECTED_SHARED && ! -L $EXPECTED_SHARED ]] &&
[[ $(realpath -e -- "$EXPECTED_STORAGE" 2>/dev/null) == "$EXPECTED_STORAGE" ]] &&
[[ $(realpath -e -- "$EXPECTED_SHARED" 2>/dev/null) == "$EXPECTED_SHARED" ]] || {
echo "omarchy-windows-vm: refusing an unsafe VM mount anchor" >&2
return 1
}
} }
__priv_up() { assert_mounts_safe && dc up -d; } __priv_up() { assert_mounts_safe && dc up -d; }
@@ -273,19 +495,24 @@ __priv_up_wait() {
__priv_status() { docker inspect --format='{{.State.Status}}' "$CONTAINER" 2>/dev/null || true; } __priv_status() { docker inspect --format='{{.State.Status}}' "$CONTAINER" 2>/dev/null || true; }
__priv_remove() { __priv_remove() {
resolve_caller || return 1
dc down 2>/dev/null || true dc down 2>/dev/null || true
docker rmi "$IMAGE" 2>/dev/null || true docker rmi "$IMAGE" 2>/dev/null || true
rm -f "$COMPOSE_FILE" rm -f "$COMPOSE_FILE"
rmdir "$RUNTIME_DIR" 2>/dev/null || true # Shared files intentionally survive removal. The storage leaf cannot be
# swapped by the user because its parent is the protected boundary.
if [[ $EXPECTED_STORAGE == "$USERS_DIR/$CALLER_UID/storage" && -d $EXPECTED_STORAGE && ! -L $EXPECTED_STORAGE ]]; then
rm -rf --one-file-system -- "$EXPECTED_STORAGE"
fi
} }
# --- config helpers ---------------------------------------------------------- # --- config helpers ----------------------------------------------------------
# Feed the collected settings to the elevated writer. # Feed the collected settings to the elevated writer.
write_compose() { write_compose() {
local ram="$1" cores="$2" disk="$3" username="$4" password="$5" tz="$6" storage="$7" shared="$8" local ram="$1" cores="$2" disk="$3" username="$4" password="$5" tz="$6"
printf 'RAM=%s\nCORES=%s\nDISK=%s\nUSERNAME=%s\nPASSWORD=%s\nTZ=%s\nSTORAGE=%s\nSHARED=%s\n' \ printf 'RAM=%s\nCORES=%s\nDISK=%s\nUSERNAME=%s\nPASSWORD=%s\nTZ=%s\n' \
"$ram" "$cores" "$disk" "$username" "$password" "$tz" "$storage" "$shared" | "$ram" "$cores" "$disk" "$username" "$password" "$tz" |
priv write_compose priv write_compose
} }
@@ -340,22 +567,17 @@ migrate_legacy_compose() {
[[ -f $LEGACY_COMPOSE_FILE ]] || return 1 [[ -f $LEGACY_COMPOSE_FILE ]] || return 1
echo "Migrating Windows VM configuration to $COMPOSE_FILE ..." echo "Migrating Windows VM configuration to $COMPOSE_FILE ..."
local ram cores disk username password tz storage shared local ram cores disk username password tz
ram=$(read_compose_value RAM_SIZE "$LEGACY_COMPOSE_FILE") ram=$(read_compose_value RAM_SIZE "$LEGACY_COMPOSE_FILE")
cores=$(read_compose_value CPU_CORES "$LEGACY_COMPOSE_FILE") cores=$(read_compose_value CPU_CORES "$LEGACY_COMPOSE_FILE")
disk=$(read_compose_value DISK_SIZE "$LEGACY_COMPOSE_FILE") disk=$(read_compose_value DISK_SIZE "$LEGACY_COMPOSE_FILE")
username=$(read_compose_value USERNAME "$LEGACY_COMPOSE_FILE") username=$(read_compose_value USERNAME "$LEGACY_COMPOSE_FILE")
password=$(read_compose_value PASSWORD "$LEGACY_COMPOSE_FILE") password=$(read_compose_value PASSWORD "$LEGACY_COMPOSE_FILE")
tz=$(read_compose_value TZ "$LEGACY_COMPOSE_FILE") tz=$(read_compose_value TZ "$LEGACY_COMPOSE_FILE")
# The VM's data always lived in the user's own ~/.windows and ~/Windows; the
# old compose only ever recorded those. Reconstruct them from $HOME (trusted —
# this runs as the user) rather than reading host paths back from a file a
# rogue process could have rewritten to bind-mount, say, / into the guest.
storage="$HOME/.windows"
shared="$HOME/Windows"
[[ -z $tz ]] && tz="UTC" [[ -z $tz ]] && tz="UTC"
if ! write_compose "$ram" "$cores" "$disk" "$username" "$password" "$tz" "$storage" "$shared"; then # The elevated writer derives both mount anchors from the authenticated uid;
# it never consumes volume paths from this user-owned legacy file.
if ! write_compose "$ram" "$cores" "$disk" "$username" "$password" "$tz"; then
echo "Could not migrate the existing configuration automatically." >&2 echo "Could not migrate the existing configuration automatically." >&2
echo "Re-run: omarchy-windows-vm install" >&2 echo "Re-run: omarchy-windows-vm install" >&2
return 1 return 1
@@ -404,7 +626,6 @@ install_windows() {
omarchy-pkg-add freerdp openbsd-netcat gum omarchy-pkg-add freerdp openbsd-netcat gum
mkdir -p "$HOME/.windows"
mkdir -p "$HOME/.local/share/applications" mkdir -p "$HOME/.local/share/applications"
cat <<EOF | tee "$HOME/.local/share/applications/windows-vm.desktop" >/dev/null cat <<EOF | tee "$HOME/.local/share/applications/windows-vm.desktop" >/dev/null
@@ -540,15 +761,14 @@ EOF
exit 1 exit 1
fi fi
mkdir -p "$HOME/Windows"
local tz local tz
tz=$(timedatectl show -p Timezone --value 2>/dev/null || echo UTC) tz=$(timedatectl show -p Timezone --value 2>/dev/null || echo UTC)
# Write the root-owned compose from the validated settings (one prompt if # Write the root-owned compose from the validated settings (one prompt if
# sudoless Docker is off), then bring the stack up. # sudoless Docker is off). The writer creates protected storage/shared mount
# anchors and leaves the familiar home entries as symlinks to them.
write_compose "$SELECTED_RAM" "$SELECTED_CORES" "$SELECTED_DISK" \ write_compose "$SELECTED_RAM" "$SELECTED_CORES" "$SELECTED_DISK" \
"$USERNAME" "$PASSWORD" "$tz" "$HOME/.windows" "$HOME/Windows" || { "$USERNAME" "$PASSWORD" "$tz" || {
echo "❌ Failed to write the Windows VM configuration." echo "❌ Failed to write the Windows VM configuration."
exit 1 exit 1
} }
+3 -1
View File
@@ -26,7 +26,9 @@ omarchy windows vm launch # start and connect
## Sharing files ## Sharing files
The directory `~/Windows` in your home directory is automatically shared with the VM. Put files there if you want them accessible to Windows. The VM has no access to any other part of your file system, so you're safe from anything nasty on the Windows side. Its own virtual disk lives in `~/.windows`. The directory `~/Windows` in your home directory is automatically shared with the VM. Put files there if you want them accessible to Windows. The VM has no access to any other part of your file system, so you're safe from anything nasty on the Windows side. Its own virtual disk is available at `~/.windows`.
Those familiar home paths are links to per-user mount anchors in a root-owned `.omarchy-windows` directory beside your home directory. Keeping the anchors on the home filesystem preserves the expected disk location, while their protected parent prevents another process running as you from swapping a checked directory for a symlink while the privileged VM is starting.
The VM's ports are bound to localhost only, so nothing on your network can reach the Windows machine. The VM's ports are bound to localhost only, so nothing on your network can reach the Windows machine.
+50 -32
View File
@@ -13,39 +13,47 @@ source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
TMPDIR=$(mktemp -d) TMPDIR=$(mktemp -d)
trap 'rm -rf "$TMPDIR"' EXIT trap 'rm -rf "$TMPDIR"' EXIT
export OMARCHY_WINDOWS_DIR="$TMPDIR/win" export OMARCHY_WINDOWS_DIR="$TMPDIR/win"
export HOME="$TMPDIR/home"
mkdir -p "$HOME"
# Source the command's functions; the dispatcher just prints usage for "help". # Source the command's functions; the dispatcher just prints usage for "help".
set -- help set -- help
source "$ROOT/bin/omarchy-windows-vm" >/dev/null 2>&1 source "$ROOT/bin/omarchy-windows-vm" >/dev/null 2>&1
COMPOSE="$OMARCHY_WINDOWS_DIR/docker-compose.yml" COMPOSE="$OMARCHY_WINDOWS_DIR/docker-compose.yml"
write() { # RAM CORES DISK USER PASS TZ STORAGE SHARED write() { # RAM CORES DISK USER PASS TZ
printf 'RAM=%s\nCORES=%s\nDISK=%s\nUSERNAME=%s\nPASSWORD=%s\nTZ=%s\nSTORAGE=%s\nSHARED=%s\n' \ printf 'RAM=%s\nCORES=%s\nDISK=%s\nUSERNAME=%s\nPASSWORD=%s\nTZ=%s\n' \
"$@" | __priv_write_compose "$@" | __priv_write_compose
} }
# --- valid compose, with the dangerous bits pinned and unreachable by input --- # --- valid compose, with the dangerous bits pinned and unreachable by input ---
rm -f "$COMPOSE" rm -f "$COMPOSE"
write 4G 2 64G alice 's3cret' Europe/Copenhagen /home/alice/.windows /home/alice/Windows write 4G 2 64G alice 's3cret' Europe/Copenhagen
resolve_caller
[[ -f $COMPOSE ]] || fail "writer produced a compose file" [[ -f $COMPOSE ]] || fail "writer produced a compose file"
grep -q 'image: dockurr/windows' "$COMPOSE" || fail "image is pinned" grep -q 'image: dockurr/windows' "$COMPOSE" || fail "image is pinned"
grep -q -- '- NET_ADMIN' "$COMPOSE" || fail "cap_add 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 -- "- $EXPECTED_STORAGE:/storage" "$COMPOSE" || fail "storage uses the per-uid protected anchor"
grep -q -- "- $EXPECTED_SHARED:/shared" "$COMPOSE" || fail "shared files use the per-uid protected anchor"
[[ -L $HOME/.windows && $(realpath "$HOME/.windows") == "$EXPECTED_STORAGE" ]] || fail "home storage link targets the protected anchor"
[[ -L $HOME/Windows && $(realpath "$HOME/Windows") == "$EXPECTED_SHARED" ]] || fail "home shared link targets the protected anchor"
grep -q -- '- /:/' "$COMPOSE" && fail "compose must never contain a host-root bind mount" grep -q -- '- /:/' "$COMPOSE" && fail "compose must never contain a host-root bind mount"
pass "writer emits a pinned compose with no host-root mount" pass "writer derives protected per-uid anchors and emits no host-root mount"
# --- injection attempts are rejected, no file written --- # --- injection attempts are rejected, no file written ---
rm -f "$COMPOSE" rm -f "$COMPOSE"
write 4G 2 64G 'x -v /:/h' p UTC /a /b 2>/dev/null && fail "malicious username was accepted" write 4G 2 64G 'x -v /:/h' p UTC 2>/dev/null && fail "malicious username was accepted"
[[ ! -f $COMPOSE ]] || fail "no compose written for a bad username" [[ ! -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" printf 'RAM=4G\nCORES=2\nDISK=64G\nUSERNAME=ok\nPASSWORD=p\nTZ=UTC\nSTORAGE=/\nSHARED=/etc\n' | __priv_write_compose
write '4G; rm -rf /' 2 64G ok p UTC /a /b 2>/dev/null && fail "malicious RAM was accepted" grep -q -- "- $EXPECTED_STORAGE:/storage" "$COMPOSE" || fail "caller-supplied storage path affected the compose"
pass "injection attempts in username, path, and RAM are rejected" grep -q -- '- /:/storage' "$COMPOSE" && fail "host root was accepted as storage"
write '4G; rm -rf /' 2 64G ok p UTC 2>/dev/null && fail "malicious RAM was accepted"
pass "injection attempts are rejected and caller-supplied paths are ignored"
# --- password survives YAML (" \) and compose interpolation ($) --- # --- password survives YAML (" \) and compose interpolation ($) ---
rm -f "$COMPOSE" rm -f "$COMPOSE"
tricky='p@$$w:rd$HOME"x\y' tricky='p@$$w:rd$HOME"x\y'
write 8G 4 64G bob "$tricky" UTC /h/.windows /h/Windows write 8G 4 64G bob "$tricky" UTC
grep -q 'PASSWORD: ".*\$\$.*"' "$COMPOSE" || fail "\$ is escaped as \$\$ for compose interpolation" grep -q 'PASSWORD: ".*\$\$.*"' "$COMPOSE" || fail "\$ is escaped as \$\$ for compose interpolation"
recovered=$(unescape "$(read_compose_value PASSWORD "$COMPOSE")") recovered=$(unescape "$(read_compose_value PASSWORD "$COMPOSE")")
[[ $recovered == "$tricky" ]] || fail "password round-trips through write/unescape" [[ $recovered == "$tricky" ]] || fail "password round-trips through write/unescape"
@@ -65,8 +73,10 @@ pass "privileged action whitelist accepts known actions and rejects the rest"
# mount host / into the guest, so migration must ignore its volume paths and # mount host / into the guest, so migration must ignore its volume paths and
# reconstruct them from the current user's $HOME. # reconstruct them from the current user's $HOME.
rm -rf "$OMARCHY_WINDOWS_DIR" rm -rf "$OMARCHY_WINDOWS_DIR"
export HOME="$TMPDIR/home" rm -rf "$MOUNT_ROOT"
mkdir -p "$HOME/.config/windows" rm -f "$HOME/.windows" "$HOME/Windows"
mkdir -p "$HOME/.config/windows" "$HOME/.windows" "$HOME/Windows"
touch "$HOME/.windows/existing-disk" "$HOME/Windows/existing-shared-file"
LEGACY_COMPOSE_FILE="$HOME/.config/windows/docker-compose.yml" LEGACY_COMPOSE_FILE="$HOME/.config/windows/docker-compose.yml"
COMPOSE_FILE="$COMPOSE" COMPOSE_FILE="$COMPOSE"
cat >"$LEGACY_COMPOSE_FILE" <<'LEG' cat >"$LEGACY_COMPOSE_FILE" <<'LEG'
@@ -88,32 +98,40 @@ priv() { local a=$1; shift; "__priv_$a" "$@"; }
migrate_legacy_compose migrate_legacy_compose
[[ -f $COMPOSE_FILE ]] || fail "migration wrote the root-owned compose" [[ -f $COMPOSE_FILE ]] || fail "migration wrote the root-owned compose"
grep -q 'USERNAME: "legacyuser"' "$COMPOSE_FILE" || fail "migration preserves settings" 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" resolve_caller
grep -q -- "- $EXPECTED_STORAGE:/storage" "$COMPOSE_FILE" || fail "migration uses the protected storage anchor"
grep -q -- "- $EXPECTED_SHARED:/shared" "$COMPOSE_FILE" || fail "migration uses the protected shared anchor"
[[ -f $EXPECTED_STORAGE/existing-disk ]] || fail "migration preserves the existing disk data"
[[ -f $EXPECTED_SHARED/existing-shared-file ]] || fail "migration preserves existing shared files"
[[ -L $HOME/.windows && -L $HOME/Windows ]] || fail "migration replaces home entries with compatibility links"
grep -q -- '- /:/' "$COMPOSE_FILE" && fail "migration must not carry a host-root bind mount from a tampered legacy file" 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" 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" [[ ! -f $LEGACY_COMPOSE_FILE ]] || fail "migration removes the legacy compose"
pass "migration reconstructs data paths from \$HOME and ignores tampered legacy volumes" pass "legacy migration pins existing data and ignores tampered legacy volumes"
# --- bring-up refuses a symlinked mount source (a symlink redirects the # --- bring-up accepts only the derived pair in a trusted compose ---
# 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" assert_mounts_safe || fail "real directory mount sources are accepted"
ln -sfn / "$TMPDIR/evilshare" sed -i "s|$EXPECTED_SHARED:/shared|/etc:/shared|" "$COMPOSE"
write 4G 2 64G dave pw UTC "$TMPDIR/realstore" "$TMPDIR/evilshare" assert_mounts_safe 2>/dev/null && fail "a tampered host path must be refused"
assert_mounts_safe && fail "a symlinked mount source must be refused" sed -i "s|/etc:/shared|$EXPECTED_SHARED:/shared|" "$COMPOSE"
pass "bring-up refuses a symlinked mount source" chmod 0666 "$COMPOSE"
assert_mounts_safe 2>/dev/null && fail "a user-writable compose must be refused"
chmod 0640 "$COMPOSE"
pass "bring-up rejects unexpected mounts and a writable compose"
# --- valid_path rejects traversal and non-normalized paths --- # --- a symlink supplied as legacy data is renamed below the protected parent
for p in /home/u/.windows /var/lib/omarchy/windows; do # before inspection, then quarantined rather than followed ---
valid_path "$p" || fail "valid_path rejected a normal path: $p" rm -rf "$OMARCHY_WINDOWS_DIR"
done rm -rf "$MOUNT_ROOT"
for p in / /./ // /tmp/../etc /home/u/. '/home/u/../root' '/a//b'; do rm -f "$HOME/.windows" "$HOME/Windows"
valid_path "$p" && fail "valid_path accepted a traversal/non-normalized path: $p" ln -s / "$HOME/.windows"
done mkdir -p "$HOME/Windows"
pass "valid_path accepts normalized paths and rejects traversal" rm -f "$COMPOSE"
write 4G 2 64G dave pw UTC 2>/dev/null && fail "a symlinked legacy data entry was accepted"
[[ ! -f $COMPOSE ]] || fail "no compose is written for a symlinked legacy entry"
[[ ! -L $MOUNT_ROOT/users/$(id -u)/storage ]] || fail "the mount anchor must not remain a symlink"
find "$MOUNT_ROOT/users/$(id -u)" -maxdepth 1 -type l -name 'rejected-storage-*' | grep -q . || fail "the rejected symlink was not quarantined"
pass "migration pins and rejects a symlinked legacy data entry"
# --- credentials are stored privately and round-trip (incl. = in password) --- # --- credentials are stored privately and round-trip (incl. = in password) ---
export CREDENTIALS_FILE="$TMPDIR/creds" export CREDENTIALS_FILE="$TMPDIR/creds"