Merge pull request #8419 from AFOliveira/security/windows-vm-mount-boundary
[codex] Secure Windows VM host mounts
This commit is contained in:
@@ -1,72 +1,119 @@
|
||||
#!/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.
|
||||
# Security regression coverage for the Windows VM compose/mount boundary.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
# Bind mounts need CAP_SYS_ADMIN in a private mount namespace. Keep the
|
||||
# caller's uid so the non-root development path is exercised.
|
||||
if [[ ${OMARCHY_WINDOWS_TEST_NAMESPACE:-0} != 1 ]]; then
|
||||
if unshare --user --map-current-user --keep-caps --mount true 2>/dev/null; then
|
||||
exec env OMARCHY_WINDOWS_TEST_NAMESPACE=1 \
|
||||
unshare --user --map-current-user --keep-caps --mount --propagation private bash "$0"
|
||||
fi
|
||||
pass "unprivileged mount namespaces unavailable; skipping Windows VM mount runtime tests"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
TMPDIR=$(mktemp -d)
|
||||
trap 'rm -rf "$TMPDIR"' EXIT
|
||||
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".
|
||||
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' \
|
||||
unmount_all() {
|
||||
local path
|
||||
resolve_caller >/dev/null 2>&1 || return 0
|
||||
for path in "$EXPECTED_SHARED" "$EXPECTED_STORAGE"; do
|
||||
while mountpoint -q -- "$path" 2>/dev/null; do umount -- "$path" || break; done
|
||||
done
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
set +e
|
||||
unmount_all
|
||||
rm -rf "$TMPDIR"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
write() { # RAM CORES DISK USER PASS TZ
|
||||
printf 'RAM=%s\nCORES=%s\nDISK=%s\nUSERNAME=%s\nPASSWORD=%s\nTZ=%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
|
||||
fd_count() { find "/proc/$$/fd" -mindepth 1 -maxdepth 1 -printf x | wc -c; }
|
||||
|
||||
reset_case() {
|
||||
unmount_all
|
||||
rm -rf "$OMARCHY_WINDOWS_DIR" "$HOME/.windows" "$HOME/Windows"
|
||||
mkdir -p "$HOME"
|
||||
}
|
||||
|
||||
# Fixed protected anchors consume the pinned source inodes.
|
||||
prepare_user_mount_sources
|
||||
write 4G 2 64G alice s3cret Europe/Copenhagen
|
||||
resolve_caller
|
||||
[[ -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"
|
||||
grep -q -- "- $EXPECTED_STORAGE:/storage" "$COMPOSE" || fail "storage uses the protected anchor"
|
||||
grep -q -- "- $EXPECTED_SHARED:/shared" "$COMPOSE" || fail "shared uses the protected anchor"
|
||||
grep -q 'PROTECT: "Y"' "$COMPOSE" || fail "web console is not password protected"
|
||||
[[ ! -L $HOME/.windows && ! -L $HOME/Windows ]] || fail "fresh sources stay real directories"
|
||||
[[ $(stat -Lc '%d:%i' "$HOME/.windows") == $(stat -Lc '%d:%i' "$EXPECTED_STORAGE") ]] || fail "storage bind did not pin source"
|
||||
[[ $(stat -Lc '%d:%i' "$HOME/Windows") == $(stat -Lc '%d:%i' "$EXPECTED_SHARED") ]] || fail "shared bind did not pin source"
|
||||
[[ $(stat -Lc '%a' "$EXPECTED_STORAGE") == 700 && $(stat -Lc '%a' "$EXPECTED_SHARED") == 700 ]] || fail "mount leaves are not private"
|
||||
grep -q -- '- /:/' "$COMPOSE" && fail "compose contains host-root bind"
|
||||
pass "writer emits fixed anchors bound to exact private source inodes"
|
||||
|
||||
# --- injection attempts are rejected, no file written ---
|
||||
# Input cannot widen a mount or compose field.
|
||||
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"
|
||||
write 4G 2 64G 'x -v /:/h' p UTC 2>/dev/null && fail "malicious username accepted"
|
||||
[[ ! -f $COMPOSE ]] || fail "bad input wrote compose"
|
||||
printf 'RAM=4G\nCORES=2\nDISK=64G\nUSERNAME=ok\nPASSWORD=p\nTZ=UTC\nSTORAGE=/\nSHARED=/etc\n' | __priv_write_compose
|
||||
grep -q -- "- $EXPECTED_STORAGE:/storage" "$COMPOSE" || fail "caller storage affected compose"
|
||||
grep -q -- '- /:/storage' "$COMPOSE" && fail "host root accepted as storage"
|
||||
write '4G; rm -rf /' 2 64G ok p UTC 2>/dev/null && fail "malicious RAM accepted"
|
||||
pass "input cannot inject a host path or compose field"
|
||||
|
||||
# --- 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"
|
||||
write 8G 4 64G bob "$tricky" UTC
|
||||
grep -q 'PASSWORD: ".*\$\$.*"' "$COMPOSE" || fail "dollar not escaped"
|
||||
[[ $(unescape "$(read_compose_value PASSWORD "$COMPOSE")") == "$tricky" ]] || fail "password did not round-trip"
|
||||
pass "password with quote, backslash, and dollar 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"
|
||||
valid_priv_action "$action" || fail "known 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]"
|
||||
valid_priv_action "$action" && fail "action whitelist accepted: [$action]"
|
||||
done
|
||||
pass "privileged action whitelist accepts known actions and rejects the rest"
|
||||
pass "privileged action dispatch is allowlisted"
|
||||
|
||||
# --- 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"
|
||||
# A PATH symlink to bash must never become the pkexec target. Hide the packaged
|
||||
# file from priv_target's stat checks to exercise the historical fallback.
|
||||
attack_bin="$TMPDIR/attack-bin"
|
||||
mkdir -p "$attack_bin"
|
||||
ln -s /bin/bash "$attack_bin/omarchy-windows-vm"
|
||||
printf 'printf exploited >"$TMPDIR/exploited"\n' >"$TMPDIR/__priv"
|
||||
stat() {
|
||||
[[ ${!#} == /usr/bin/omarchy-windows-vm ]] && return 1
|
||||
command stat "$@"
|
||||
}
|
||||
PATH="$attack_bin:$PATH" priv_target >/dev/null 2>&1 && fail "PATH symlink became a privileged target"
|
||||
unset -f stat
|
||||
[[ ! -e $TMPDIR/exploited ]] || fail "attacker __priv script executed"
|
||||
pass "pkexec target is only the canonical packaged regular file, never a PATH symlink"
|
||||
|
||||
# Legacy migration keeps directories and legitimate symlinks in place.
|
||||
reset_case
|
||||
external_shared="$TMPDIR/external-shared"
|
||||
mkdir -m 0755 -p "$HOME/.windows" "$external_shared" "$HOME/.config/windows"
|
||||
ln -s "$external_shared" "$HOME/Windows"
|
||||
touch "$HOME/.windows/existing-disk" "$external_shared/existing-shared-file"
|
||||
LEGACY_COMPOSE_FILE="$HOME/.config/windows/docker-compose.yml"
|
||||
COMPOSE_FILE="$COMPOSE"
|
||||
cat >"$LEGACY_COMPOSE_FILE" <<'LEG'
|
||||
@@ -83,42 +130,364 @@ services:
|
||||
- /./:/storage
|
||||
- /etc:/shared
|
||||
LEG
|
||||
# In production the write elevates via pkexec; here run it in-process.
|
||||
priv() { local a=$1; shift; "__priv_$a" "$@"; }
|
||||
priv() { local action=$1; shift; "__priv_$action" "$@"; }
|
||||
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"
|
||||
resolve_caller
|
||||
[[ -f $COMPOSE ]] || fail "migration did not write compose"
|
||||
grep -q 'USERNAME: "legacyuser"' "$COMPOSE" || fail "migration lost settings"
|
||||
[[ -f $HOME/.windows/existing-disk && -f $external_shared/existing-shared-file ]] || fail "migration lost data"
|
||||
[[ ! -L $HOME/.windows && $(readlink "$HOME/Windows") == "$external_shared" ]] || fail "migration consumed source path"
|
||||
[[ $(stat -Lc '%a' "$HOME/.windows") == 700 && $(stat -Lc '%a' "$external_shared") == 700 ]] || fail "migration did not harden legacy directories"
|
||||
grep -q -- '- /:/' "$COMPOSE" && fail "migration copied malicious storage"
|
||||
grep -q -- '- /etc:/shared' "$COMPOSE" && fail "migration copied malicious share"
|
||||
[[ ! -f $LEGACY_COMPOSE_FILE ]] || fail "migration left legacy compose"
|
||||
pass "migration preserves data and symlinks while hardening permissions"
|
||||
|
||||
# --- 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"
|
||||
# Bring-up re-proves compose trust, cardinality, and mounted identities.
|
||||
assert_mounts_safe || fail "verified sources rejected"
|
||||
sed -i "s|$EXPECTED_SHARED:/shared|/etc:/shared|" "$COMPOSE"
|
||||
assert_mounts_safe 2>/dev/null && fail "tampered host path accepted"
|
||||
sed -i "s|/etc:/shared|$EXPECTED_SHARED:/shared|" "$COMPOSE"
|
||||
printf ' - %s:/storage\n' "$EXPECTED_STORAGE" >>"$COMPOSE"
|
||||
assert_mounts_safe 2>/dev/null && fail "duplicate destination accepted"
|
||||
write 16G 6 128G legacyuser legacypass America/New_York
|
||||
sed -i 's/PROTECT: "Y"/PROTECT: "N"/' "$COMPOSE"
|
||||
assert_mounts_safe 2>/dev/null && fail "unprotected web console accepted"
|
||||
sed -i 's/PROTECT: "N"/PROTECT: "Y"/' "$COMPOSE"
|
||||
printf ' PROTECT: "N"\n' >>"$COMPOSE"
|
||||
assert_mounts_safe 2>/dev/null && fail "duplicate web protection setting accepted"
|
||||
sed -i '$d' "$COMPOSE"
|
||||
chmod 0666 "$COMPOSE"
|
||||
assert_mounts_safe 2>/dev/null && fail "writable compose accepted"
|
||||
chmod 0640 "$COMPOSE"
|
||||
pass "bring-up rejects tampered, duplicate, unprotected, and writable compose inputs"
|
||||
|
||||
# --- 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"
|
||||
# Both sources are pinned before a bind; bad symlinks stay untouched.
|
||||
reset_case
|
||||
mkdir -p "$HOME/.windows"
|
||||
ln -s / "$HOME/Windows"
|
||||
before_fds=$(fd_count)
|
||||
prepare_user_mount_sources 2>/dev/null && fail "root symlink passed user preflight"
|
||||
[[ -L $HOME/Windows && $(readlink "$HOME/Windows") == / ]] || fail "rejected symlink consumed"
|
||||
printf 'RAM=4G\nCORES=2\nDISK=64G\nUSERNAME=x\nPASSWORD=p\nTZ=UTC\n' | __priv_write_compose 2>/dev/null && fail "root symlink passed privileged preflight"
|
||||
resolve_caller
|
||||
[[ $(mount_layer_count "$EXPECTED_STORAGE") == 0 && $(mount_layer_count "$EXPECTED_SHARED") == 0 ]] || fail "one source mounted before other failed"
|
||||
[[ $(fd_count) == "$before_fds" ]] || fail "source preflight leaked FD"
|
||||
find "$CALLER_DATA_ROOT" -name 'rejected-*' -print -quit | grep -q . && fail "source was quarantined"
|
||||
pass "invalid second source leaves paths and anchors untouched and leaks no FD"
|
||||
|
||||
# Distinct caller-owned symlink targets are supported and remain links.
|
||||
reset_case
|
||||
external_storage="$TMPDIR/external-storage"
|
||||
external_shared2="$TMPDIR/external-shared-2"
|
||||
mkdir -p "$external_storage" "$external_shared2"
|
||||
ln -s "$external_storage" "$HOME/.windows"
|
||||
ln -s "$external_shared2" "$HOME/Windows"
|
||||
prepare_user_mount_sources
|
||||
write 4G 2 64G symlinked pw UTC
|
||||
resolve_caller
|
||||
[[ $(readlink "$HOME/.windows") == "$external_storage" && $(readlink "$HOME/Windows") == "$external_shared2" ]] || fail "writer replaced symlinks"
|
||||
[[ $(stat -Lc '%d:%i' "$EXPECTED_STORAGE") == $(stat -Lc '%d:%i' "$external_storage") ]] || fail "symlink target not pinned"
|
||||
pass "legitimate caller-owned symlinks remain in place"
|
||||
|
||||
# Reproduce the original post-validation race at the last possible moment:
|
||||
# replace the familiar shared path with / only after the final guard returns,
|
||||
# inside the mocked Docker Compose invocation. Compose must still consume the
|
||||
# protected anchor bound to the inode that was validated earlier.
|
||||
raced_shared="$HOME/Windows.before-race"
|
||||
shared_id_before_race=$(stat -Lc '%d:%i' "$external_shared2")
|
||||
race_ran=0
|
||||
dc() {
|
||||
[[ $1 == up && ${2:-} == -d ]] || return 1
|
||||
mv -T -- "$HOME/Windows" "$raced_shared"
|
||||
ln -s / "$HOME/Windows"
|
||||
race_ran=1
|
||||
[[ $(get_mount_source /shared) == "$EXPECTED_SHARED" ]] || return 1
|
||||
[[ $(stat -Lc '%d:%i' "$EXPECTED_SHARED") == "$shared_id_before_race" ]] || return 1
|
||||
}
|
||||
__priv_up || fail "post-validation home-path swap changed the Docker mount source"
|
||||
(( race_ran == 1 )) || fail "post-validation race hook did not run"
|
||||
[[ -L $HOME/Windows && $(readlink "$HOME/Windows") == / ]] || fail "race did not replace the familiar shared path"
|
||||
rm "$HOME/Windows"
|
||||
mv -T -- "$raced_shared" "$HOME/Windows"
|
||||
unset -f dc
|
||||
pass "a post-validation path swap cannot redirect Docker away from the pinned shared inode"
|
||||
|
||||
# Run the same attack as a genuinely concurrent process. A successful bring-up
|
||||
# deliberately waits inside the Docker boundary until the attacker has replaced
|
||||
# the familiar path with /, then verifies that the real bind anchor still names
|
||||
# the caller-owned directory that was pinned before the race.
|
||||
reset_case
|
||||
prepare_user_mount_sources
|
||||
touch "$HOME/Windows/safe-marker"
|
||||
write 4G 2 64G concurrent pw UTC
|
||||
resolve_caller
|
||||
concurrent_shared_id=$(stat -Lc '%d:%i' "$HOME/Windows")
|
||||
host_root_id=$(stat -Lc '%d:%i' /)
|
||||
race_source="$HOME/Windows.race-source"
|
||||
race_stop="$TMPDIR/stop-concurrent-race"
|
||||
race_swaps="$TMPDIR/concurrent-race-swaps"
|
||||
(
|
||||
set +e
|
||||
while [[ ! -e $race_stop ]]; do
|
||||
if [[ -d $HOME/Windows && ! -L $HOME/Windows ]] && mv -T -- "$HOME/Windows" "$race_source" 2>/dev/null; then
|
||||
ln -s / "$HOME/Windows" 2>/dev/null || true
|
||||
printf x >>"$race_swaps"
|
||||
sleep 0.002
|
||||
fi
|
||||
if [[ -L $HOME/Windows ]]; then
|
||||
rm -f -- "$HOME/Windows"
|
||||
mv -T -- "$race_source" "$HOME/Windows" 2>/dev/null || true
|
||||
sleep 0.005
|
||||
fi
|
||||
done
|
||||
) &
|
||||
racer_pid=$!
|
||||
concurrent_dc_calls=0
|
||||
dc() {
|
||||
local attempt
|
||||
[[ $1 == up && ${2:-} == -d ]] || return 1
|
||||
for ((attempt = 0; attempt < 20000; attempt++)); do
|
||||
if [[ -L $HOME/Windows && $(readlink "$HOME/Windows" 2>/dev/null) == / ]]; then
|
||||
break
|
||||
fi
|
||||
done
|
||||
[[ -L $HOME/Windows && $(readlink "$HOME/Windows" 2>/dev/null) == / ]] || return 1
|
||||
((concurrent_dc_calls++))
|
||||
[[ $(get_mount_source /shared) == "$EXPECTED_SHARED" ]] || return 1
|
||||
[[ $(stat -Lc '%d:%i' "$EXPECTED_SHARED") == "$concurrent_shared_id" ]] || return 1
|
||||
[[ $(stat -Lc '%d:%i' "$EXPECTED_SHARED") != "$host_root_id" ]] || return 1
|
||||
[[ -f $EXPECTED_SHARED/safe-marker ]]
|
||||
}
|
||||
for ((attempt = 0; attempt < 200; attempt++)); do
|
||||
if __priv_up 2>/dev/null; then break; fi
|
||||
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"
|
||||
touch "$race_stop"
|
||||
wait "$racer_pid"
|
||||
unset -f dc
|
||||
if [[ -L $HOME/Windows ]]; then rm -f -- "$HOME/Windows"; fi
|
||||
if [[ ! -e $HOME/Windows && -d $race_source ]]; then mv -T -- "$race_source" "$HOME/Windows"; fi
|
||||
[[ -s $race_swaps ]] || fail "concurrent attacker never swapped the shared path"
|
||||
((concurrent_dc_calls > 0)) || fail "concurrent race never reached Docker while the familiar path named host root"
|
||||
[[ $(stat -Lc '%d:%i' "$EXPECTED_SHARED") == "$concurrent_shared_id" ]] || fail "concurrent race changed the protected shared inode"
|
||||
pass "a concurrent home-path swap cannot redirect Docker away from the pinned shared inode"
|
||||
|
||||
# --- 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"
|
||||
# Same-inode sources fail before mounting and close both descriptors.
|
||||
reset_case
|
||||
same="$TMPDIR/same-source"
|
||||
mkdir -p "$same"
|
||||
ln -s "$same" "$HOME/.windows"
|
||||
ln -s "$same" "$HOME/Windows"
|
||||
before_fds=$(fd_count)
|
||||
prepare_user_mount_sources 2>/dev/null && fail "same source passed user preflight"
|
||||
printf 'RAM=4G\nCORES=2\nDISK=64G\nUSERNAME=x\nPASSWORD=p\nTZ=UTC\n' | __priv_write_compose 2>/dev/null && fail "same source passed root preflight"
|
||||
resolve_caller
|
||||
[[ $(mount_layer_count "$EXPECTED_STORAGE") == 0 && $(mount_layer_count "$EXPECTED_SHARED") == 0 ]] || fail "same source left mount"
|
||||
[[ $(fd_count) == "$before_fds" ]] || fail "same source leaked FDs"
|
||||
pass "storage and shared must differ and failure closes FDs"
|
||||
|
||||
# Ancestor/descendant aliases are just as destructive as same-inode aliases:
|
||||
# removal must never recurse from storage into shared (or accept the inverse).
|
||||
reset_case
|
||||
shared_inside="$TMPDIR/shared-inside-storage"
|
||||
mkdir -p "$shared_inside/storage/shared"
|
||||
ln -s "$shared_inside/storage" "$HOME/.windows"
|
||||
ln -s "$shared_inside/storage/shared" "$HOME/Windows"
|
||||
prepare_user_mount_sources
|
||||
before_fds=$(fd_count)
|
||||
write 4G 2 64G nested pw UTC 2>/dev/null && fail "shared-inside-storage sources were accepted"
|
||||
resolve_caller
|
||||
[[ $(mount_layer_count "$EXPECTED_STORAGE") == 0 && $(mount_layer_count "$EXPECTED_SHARED") == 0 ]] || fail "shared-inside-storage failure left a mount"
|
||||
[[ $(fd_count) == "$before_fds" ]] || fail "shared-inside-storage failure leaked FDs"
|
||||
|
||||
reset_case
|
||||
storage_inside="$TMPDIR/storage-inside-shared"
|
||||
mkdir -p "$storage_inside/shared/storage"
|
||||
ln -s "$storage_inside/shared/storage" "$HOME/.windows"
|
||||
ln -s "$storage_inside/shared" "$HOME/Windows"
|
||||
prepare_user_mount_sources
|
||||
before_fds=$(fd_count)
|
||||
write 4G 2 64G nested pw UTC 2>/dev/null && fail "storage-inside-shared sources were accepted"
|
||||
resolve_caller
|
||||
[[ $(mount_layer_count "$EXPECTED_STORAGE") == 0 && $(mount_layer_count "$EXPECTED_SHARED") == 0 ]] || fail "storage-inside-shared failure left a mount"
|
||||
[[ $(fd_count) == "$before_fds" ]] || fail "storage-inside-shared failure leaked FDs"
|
||||
pass "pinned-FD ancestry checks reject overlap in both directions before mounting"
|
||||
|
||||
# Exact bind-alias bypass regression: the shared FD's visible parent is the
|
||||
# alias directory, but its inode is still reachable below storage.
|
||||
reset_case
|
||||
alias_under="$TMPDIR/bind-alias-under"
|
||||
alias_shared="$TMPDIR/bind-alias-shared"
|
||||
mkdir -p "$alias_under/storage/shared" "$alias_shared"
|
||||
mount --no-canonicalize --bind "$alias_under/storage/shared" "$alias_shared"
|
||||
ln -s "$alias_under/storage" "$HOME/.windows"
|
||||
ln -s "$alias_shared" "$HOME/Windows"
|
||||
prepare_user_mount_sources
|
||||
before_fds=$(fd_count)
|
||||
resolve_caller
|
||||
open_mount_source "$LEGACY_STORAGE" storage
|
||||
alias_storage_fd=$OPENED_MOUNT_FD
|
||||
alias_storage_id=$OPENED_MOUNT_ID
|
||||
open_mount_source "$LEGACY_SHARED" shared
|
||||
alias_shared_fd=$OPENED_MOUNT_FD
|
||||
pinned_dir_contains "$alias_storage_id" "$alias_shared_fd" && fail "bind-alias repro unexpectedly shared the underlying parent walk"
|
||||
pinned_tree_contains "$alias_storage_fd" "$alias_shared_fd" || fail "tree-rooted discovery missed the bind-alias inode"
|
||||
exec {alias_storage_fd}<&-
|
||||
exec {alias_shared_fd}<&-
|
||||
write 4G 2 64G alias pw UTC
|
||||
resolve_caller
|
||||
touch "$HOME/.windows/disk.img" "$HOME/Windows/keep.txt"
|
||||
dc() { :; }
|
||||
docker() { [[ $1 == inspect ]] && return 1; :; }
|
||||
__priv_remove 2>/dev/null && fail "removal accepted a shared bind alias into storage"
|
||||
[[ -f $HOME/.windows/disk.img && -f $HOME/Windows/keep.txt && -f $COMPOSE ]] || fail "bind-alias removal refusal changed state"
|
||||
[[ $(mount_layer_count "$EXPECTED_STORAGE") == 1 && $(mount_layer_count "$EXPECTED_SHARED") == 1 ]] || fail "bind-alias removal refusal changed mounts"
|
||||
[[ $(fd_count) == "$before_fds" ]] || fail "bind-alias removal refusal leaked FDs"
|
||||
unmount_all
|
||||
umount -- "$alias_shared"
|
||||
pass "cheap startup permits a bind alias, but bounded removal discovery refuses it"
|
||||
|
||||
# A late writer failure rolls back both newly-created binds.
|
||||
reset_case
|
||||
prepare_user_mount_sources
|
||||
mv() { return 1; }
|
||||
write 4G 2 64G rollback pw UTC 2>/dev/null && fail "forced writer failure succeeded"
|
||||
unset -f mv
|
||||
resolve_caller
|
||||
[[ $(mount_layer_count "$EXPECTED_STORAGE") == 0 && $(mount_layer_count "$EXPECTED_SHARED") == 0 ]] || fail "writer failure left binds"
|
||||
[[ ! -f $COMPOSE ]] || fail "writer failure replaced compose"
|
||||
pass "atomic writer failure rolls back both new bind mounts"
|
||||
|
||||
# Revalidate ancestry during removal: move the already-bound shared inode below
|
||||
# storage, keep its familiar path as a symlink, and prove nothing is deleted.
|
||||
reset_case
|
||||
prepare_user_mount_sources
|
||||
write 4G 2 64G moved pw UTC
|
||||
touch "$HOME/.windows/disk.img" "$HOME/Windows/keep.txt"
|
||||
mv "$HOME/Windows" "$HOME/.windows/moved-shared"
|
||||
ln -s "$HOME/.windows/moved-shared" "$HOME/Windows"
|
||||
dc() { :; }
|
||||
docker() { [[ $1 == inspect ]] && return 1; :; }
|
||||
__priv_remove 2>/dev/null && fail "removal accepted a shared inode moved below storage"
|
||||
[[ -f $HOME/.windows/disk.img && -f $HOME/.windows/moved-shared/keep.txt && -f $COMPOSE ]] || fail "overlap rejection changed disk, shared data, or compose"
|
||||
pass "removal revalidates pinned ancestry and leaves moved shared data untouched"
|
||||
|
||||
# Even when both familiar paths remain disjoint, a same-filesystem bind of the
|
||||
# pinned shared inode introduced below storage must stop removal before change.
|
||||
reset_case
|
||||
prepare_user_mount_sources
|
||||
write 4G 2 64G removal-alias pw UTC
|
||||
touch "$HOME/.windows/disk.img" "$HOME/Windows/keep.txt"
|
||||
mkdir "$HOME/.windows/shared-bind-alias"
|
||||
mount --no-canonicalize --bind "$HOME/Windows" "$HOME/.windows/shared-bind-alias"
|
||||
__priv_remove 2>/dev/null && fail "removal missed a shared bind alias introduced below storage"
|
||||
[[ -f $HOME/.windows/disk.img && -f $HOME/Windows/keep.txt && -f $COMPOSE ]] || fail "removal bind-alias rejection changed state"
|
||||
umount -- "$HOME/.windows/shared-bind-alias"
|
||||
pass "removal tree discovery catches a shared alias not used by either home path"
|
||||
|
||||
# A direct alias on another filesystem is still visited by find -xdev at its
|
||||
# mountpoint and must be rejected, while unrelated separate filesystems remain
|
||||
# supported by the root suite.
|
||||
reset_case
|
||||
prepare_user_mount_sources
|
||||
mount -t tmpfs -o uid="$(id -u)",gid="$(id -g)",mode=0700,size=8m crossdev-shared "$HOME/Windows"
|
||||
touch "$HOME/Windows/keep.txt"
|
||||
write 4G 2 64G crossdev-alias pw UTC
|
||||
touch "$HOME/.windows/disk.img"
|
||||
mkdir "$HOME/.windows/crossdev-shared-alias"
|
||||
mount --no-canonicalize --bind "$HOME/Windows" "$HOME/.windows/crossdev-shared-alias"
|
||||
__priv_remove 2>/dev/null && fail "removal missed a different-device shared alias below storage"
|
||||
[[ -f $HOME/.windows/disk.img && -f $HOME/Windows/keep.txt && -f $COMPOSE ]] || fail "cross-device alias rejection changed state"
|
||||
umount -- "$HOME/.windows/crossdev-shared-alias"
|
||||
unmount_all
|
||||
umount -- "$HOME/Windows"
|
||||
pass "removal catches a direct different-filesystem shared alias at the xdev boundary"
|
||||
|
||||
# Recursive alias discovery is destructive-removal-only and bounded. A hung or
|
||||
# failing scanner must fail closed before the disk, share, compose, or mounts
|
||||
# are changed.
|
||||
reset_case
|
||||
prepare_user_mount_sources
|
||||
write 4G 2 64G scan-failure pw UTC
|
||||
touch "$HOME/.windows/disk.img" "$HOME/Windows/keep.txt"
|
||||
scan_helper="$TMPDIR/tree-scan-helper"
|
||||
saved_tree_scan_find=$TREE_SCAN_FIND
|
||||
saved_tree_scan_timeout=$TREE_SCAN_TIMEOUT_SECONDS
|
||||
saved_tree_scan_kill_after=$TREE_SCAN_KILL_AFTER_SECONDS
|
||||
printf '#!/bin/bash\n/bin/sleep 10\n' >"$scan_helper"
|
||||
chmod 0700 "$scan_helper"
|
||||
TREE_SCAN_FIND=$scan_helper
|
||||
TREE_SCAN_TIMEOUT_SECONDS=0.05
|
||||
TREE_SCAN_KILL_AFTER_SECONDS=0.05
|
||||
__priv_remove 2>/dev/null && fail "removal continued after its containment scan timed out"
|
||||
[[ -f $HOME/.windows/disk.img && -f $HOME/Windows/keep.txt && -f $COMPOSE ]] || fail "timed-out containment scan changed state"
|
||||
[[ $(mount_layer_count "$EXPECTED_STORAGE") == 1 && $(mount_layer_count "$EXPECTED_SHARED") == 1 ]] || fail "timed-out containment scan changed mounts"
|
||||
|
||||
printf '#!/bin/bash\nexit 42\n' >"$scan_helper"
|
||||
__priv_remove 2>/dev/null && fail "removal continued after its containment scanner failed"
|
||||
[[ -f $HOME/.windows/disk.img && -f $HOME/Windows/keep.txt && -f $COMPOSE ]] || fail "failed containment scan changed state"
|
||||
[[ $(mount_layer_count "$EXPECTED_STORAGE") == 1 && $(mount_layer_count "$EXPECTED_SHARED") == 1 ]] || fail "failed containment scan changed mounts"
|
||||
TREE_SCAN_FIND=$saved_tree_scan_find
|
||||
TREE_SCAN_TIMEOUT_SECONDS=$saved_tree_scan_timeout
|
||||
TREE_SCAN_KILL_AFTER_SECONDS=$saved_tree_scan_kill_after
|
||||
pass "removal scan timeout and errors fail closed without changing VM state"
|
||||
|
||||
# Removal rejects stacks, then deletes disk only through verified binds.
|
||||
reset_case
|
||||
prepare_user_mount_sources
|
||||
write 4G 2 64G remove pw UTC
|
||||
resolve_caller
|
||||
touch "$HOME/.windows/disk.img" "$HOME/Windows/keep.txt"
|
||||
mount --no-canonicalize --bind "$HOME/.windows" "$EXPECTED_STORAGE"
|
||||
dc() { :; }
|
||||
docker() { [[ $1 == inspect ]] && return 1; :; }
|
||||
__priv_remove 2>/dev/null && fail "removal accepted stacked storage mount"
|
||||
[[ -f $HOME/.windows/disk.img && -f $HOME/Windows/keep.txt && -f $COMPOSE ]] || fail "rejected removal changed state"
|
||||
umount -- "$EXPECTED_STORAGE"
|
||||
dc() { return 1; }
|
||||
__priv_remove 2>/dev/null && fail "removal deleted data after docker-compose down failed"
|
||||
[[ -f $HOME/.windows/disk.img && -f $HOME/Windows/keep.txt && -f $COMPOSE ]] || fail "failed down changed data or compose"
|
||||
dc() { :; }
|
||||
__priv_remove
|
||||
[[ ! -e $HOME/.windows/disk.img ]] || fail "removal preserved disk data"
|
||||
[[ -e $HOME/Windows/keep.txt ]] || fail "removal deleted shared data"
|
||||
[[ ! -f $COMPOSE ]] || fail "removal left compose"
|
||||
resolve_caller
|
||||
[[ $(mount_layer_count "$EXPECTED_STORAGE") == 0 && $(mount_layer_count "$EXPECTED_SHARED") == 0 ]] || fail "removal left binds"
|
||||
pass "removal rejects stacks, deletes disk, and preserves shared files"
|
||||
|
||||
# Credentials replace a planted link rather than following it, and a failed
|
||||
# atomic rename preserves the last complete private file.
|
||||
credentials_dir="$TMPDIR/credentials"
|
||||
CREDENTIALS_FILE="$credentials_dir/credentials"
|
||||
credentials_victim="$TMPDIR/credentials-victim"
|
||||
mkdir -m 0755 -p "$credentials_dir"
|
||||
printf 'victim\n' >"$credentials_victim"
|
||||
ln -s "$credentials_victim" "$CREDENTIALS_FILE"
|
||||
write_credentials carol 'p=a$$w"x'
|
||||
[[ -f $CREDENTIALS_FILE && ! -L $CREDENTIALS_FILE ]] || fail "credentials did not replace a planted symlink"
|
||||
[[ $(stat -c '%a' "$credentials_dir") == 700 && $(stat -c '%a' "$CREDENTIALS_FILE") == 600 ]] || fail "credentials path is not private"
|
||||
[[ $(cat "$credentials_victim") == victim ]] || fail "credentials write changed a symlink victim"
|
||||
[[ $(read_credential USERNAME) == carol && $(read_credential PASSWORD) == 'p=a$$w"x' ]] || fail "credentials did not round-trip"
|
||||
credentials_before=$(cat "$CREDENTIALS_FILE")
|
||||
mv() { return 1; }
|
||||
write_credentials changed replacement 2>/dev/null && fail "forced credentials rename failure succeeded"
|
||||
unset -f mv
|
||||
[[ $(cat "$CREDENTIALS_FILE") == "$credentials_before" ]] || fail "failed credentials rename replaced the live file"
|
||||
! find "$credentials_dir" -name '.credentials.*' -print -quit | grep -q . || fail "failed credentials write left a temporary file"
|
||||
pass "credentials are atomically replaced as a private regular file"
|
||||
|
||||
# Free-space accounting follows the real storage target.
|
||||
reset_case
|
||||
mkdir -p "$external_storage" "$HOME/Windows"
|
||||
ln -s "$external_storage" "$HOME/.windows"
|
||||
prepare_user_mount_sources
|
||||
df_log="$TMPDIR/df-path"
|
||||
df() {
|
||||
printf '%s\n' "${!#}" >"$df_log"
|
||||
printf 'Filesystem 1024-blocks Used Available Capacity Mounted on\nmock 104857600 0 94371840 0%% /mock\n'
|
||||
}
|
||||
[[ $(available_storage_gb) == 90 ]] || fail "free-space parsed wrong value"
|
||||
unset -f df
|
||||
[[ $(cat "$df_log") == "$external_storage" ]] || fail "free-space used home filesystem"
|
||||
pass "disk-space checks follow the storage symlink target"
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
#!/bin/bash
|
||||
# Exercise the real EUID-0/PKEXEC_UID boundary in an isolated user+mount namespace.
|
||||
|
||||
set -euo pipefail
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
if ((EUID != 0)); then
|
||||
if unshare --user --map-auto --map-root-user --mount true 2>/dev/null; then
|
||||
exec unshare --user --map-auto --map-root-user --mount --propagation private bash "$0"
|
||||
fi
|
||||
pass "automatic subordinate-id namespace unavailable; skipping root Windows VM boundary probe"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
test_tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$test_tmp"' EXIT
|
||||
|
||||
# Hide host state before creating the production paths used by the root helper.
|
||||
mount -t tmpfs -o mode=0755,size=8m run-test /run
|
||||
mkdir -p /run/lock
|
||||
mount -t tmpfs -o mode=0755,size=16m var-test /var
|
||||
mkdir -p /var/lib/omarchy
|
||||
mount -t tmpfs -o mode=0755,size=16m home-parent /home
|
||||
mkdir /home/alice
|
||||
mount -t tmpfs -o uid=0,gid=0,mode=0710,size=1g home-alice /home/alice
|
||||
|
||||
export HOME=/home/alice
|
||||
unset OMARCHY_WINDOWS_DIR
|
||||
set -- help
|
||||
source "$ROOT/bin/omarchy-windows-vm" >/dev/null 2>&1
|
||||
|
||||
# The namespace maps the host filesystem's uid 0 to nobody. Only / remains on
|
||||
# that filesystem; all paths the helper mutates are isolated tmpfs mounts.
|
||||
stat() {
|
||||
if [[ ${!#} == / && $* == *"%u"* ]]; then printf '0\n'; return; fi
|
||||
command stat "$@"
|
||||
}
|
||||
|
||||
TEST_PASSWD_HOME=/home/alice
|
||||
getent() {
|
||||
if [[ $1 == passwd && ${2:-} == 1000 ]]; then
|
||||
printf 'alice:x:1000:1000::%s:/bin/bash\n' "$TEST_PASSWD_HOME"
|
||||
return 0
|
||||
fi
|
||||
return 2
|
||||
}
|
||||
|
||||
assert_no_runtime_mutation() {
|
||||
[[ ! -e /var/lib/omarchy/windows && ! -L /var/lib/omarchy/windows ]] ||
|
||||
fail "$1 mutated the production runtime"
|
||||
}
|
||||
|
||||
unset PKEXEC_UID
|
||||
resolve_caller 2>/dev/null && fail "root accepted missing PKEXEC_UID"
|
||||
assert_no_runtime_mutation "missing PKEXEC_UID"
|
||||
PKEXEC_UID=0
|
||||
resolve_caller 2>/dev/null && fail "root accepted PKEXEC_UID=0"
|
||||
assert_no_runtime_mutation "zero PKEXEC_UID"
|
||||
PKEXEC_UID=not-a-number
|
||||
resolve_caller 2>/dev/null && fail "root accepted nonnumeric PKEXEC_UID"
|
||||
assert_no_runtime_mutation "nonnumeric PKEXEC_UID"
|
||||
PKEXEC_UID=1001
|
||||
resolve_caller 2>/dev/null && fail "root accepted uid absent from passwd"
|
||||
assert_no_runtime_mutation "missing passwd entry"
|
||||
|
||||
PKEXEC_UID=1000
|
||||
resolve_caller 2>/dev/null && fail "root accepted a home not owned by caller"
|
||||
assert_no_runtime_mutation "wrong-owned home"
|
||||
chown 1000:1000 /home/alice
|
||||
|
||||
chmod 0777 /home
|
||||
resolve_caller 2>/dev/null && fail "root accepted writable home parent"
|
||||
assert_no_runtime_mutation "writable parent"
|
||||
chmod 0755 /home
|
||||
|
||||
mkdir /home/real-alice
|
||||
chown 1000:1000 /home/real-alice
|
||||
ln -s /home/real-alice /home/link-alice
|
||||
TEST_PASSWD_HOME=/home/link-alice
|
||||
resolve_caller 2>/dev/null && fail "root accepted symlinked passwd home"
|
||||
assert_no_runtime_mutation "symlinked home"
|
||||
TEST_PASSWD_HOME=/home/alice
|
||||
resolve_caller || fail "valid root PKEXEC_UID/home boundary was rejected"
|
||||
pass "root dispatch rejects missing/invalid uid, passwd, owner, symlink, and writable-parent boundaries without mutation"
|
||||
|
||||
# Put each familiar source on its own filesystem. Both start with legacy 0755
|
||||
# permissions and world-readable payloads to prove migration hardens the leaves.
|
||||
mkdir /home/storage-target /home/shared-target
|
||||
mount -t tmpfs -o uid=1000,gid=1000,mode=0755,size=3g storage-test /home/storage-target
|
||||
mount -t tmpfs -o uid=1000,gid=1000,mode=0755,size=64m shared-test /home/shared-target
|
||||
ln -s /home/storage-target /home/alice/.windows
|
||||
ln -s /home/shared-target /home/alice/Windows
|
||||
chown -h 1000:1000 /home/alice/.windows /home/alice/Windows
|
||||
printf disk >/home/storage-target/disk.img
|
||||
printf shared >/home/shared-target/shared.txt
|
||||
chown 1000:1000 /home/storage-target/disk.img /home/shared-target/shared.txt
|
||||
chmod 0644 /home/storage-target/disk.img /home/shared-target/shared.txt
|
||||
|
||||
home_dev=$(command stat -Lc '%d' /home/alice)
|
||||
storage_dev=$(command stat -Lc '%d' /home/storage-target)
|
||||
[[ $home_dev != "$storage_dev" ]] || fail "storage target did not land on a separate filesystem"
|
||||
|
||||
with_vm_lock prepare_caller_mounts || fail "root could not create verified production bind anchors"
|
||||
resolve_caller
|
||||
[[ $(readlink /home/alice/.windows) == /home/storage-target &&
|
||||
$(readlink /home/alice/Windows) == /home/shared-target ]] || fail "root consumed legitimate symlinks"
|
||||
[[ $(command stat -Lc '%d:%i' "$EXPECTED_STORAGE") == $(command stat -Lc '%d:%i' /home/storage-target) ]] || fail "storage bind identity differs from pinned source"
|
||||
[[ $(command stat -Lc '%d:%i' "$EXPECTED_SHARED") == $(command stat -Lc '%d:%i' /home/shared-target) ]] || fail "shared bind identity differs from pinned source"
|
||||
[[ $(command stat -Lc '%d' "$CALLER_DATA_ROOT") != "$storage_dev" ]] || fail "Docker boundary unexpectedly shares the storage filesystem"
|
||||
[[ $(command stat -Lc '%u:%a' "$MOUNT_ROOT") == 0:711 &&
|
||||
$(command stat -Lc '%u:%a' "$CALLER_DATA_ROOT") == 0:711 ]] || fail "production ancestors are not root-owned/private-boundary modes"
|
||||
[[ $(command stat -Lc '%u:%a' "$EXPECTED_STORAGE") == 1000:700 &&
|
||||
$(command stat -Lc '%u:%a' "$EXPECTED_SHARED") == 1000:700 ]] || fail "migrated leaves are not caller-owned 0700"
|
||||
if setpriv --reuid=1001 --regid=1001 --clear-groups cat "$EXPECTED_STORAGE/disk.img" >/dev/null 2>&1; then
|
||||
fail "another local account read the VM disk through its anchor"
|
||||
fi
|
||||
if setpriv --reuid=1001 --regid=1001 --clear-groups cat "$EXPECTED_SHARED/shared.txt" >/dev/null 2>&1; then
|
||||
fail "another local account read shared files through their anchor"
|
||||
fi
|
||||
pass "cross-filesystem symlink sources bind by identity and migrated 0700 leaves deny another account"
|
||||
|
||||
# Existing production boundary components are never repaired in place when
|
||||
# their ownership or write permissions are unsafe. Both the preparation path
|
||||
# and the final pre-Docker guard must fail closed without disturbing the binds.
|
||||
chmod 0731 "$MOUNT_ROOT"
|
||||
with_vm_lock prepare_caller_mounts 2>/dev/null && fail "root repaired a group-writable mount boundary instead of rejecting it"
|
||||
mounts_ready 2>/dev/null && fail "final guard accepted a group-writable mount boundary"
|
||||
[[ $(command stat -Lc '%a' "$MOUNT_ROOT") == 731 ]] || fail "rejection unexpectedly changed the writable boundary"
|
||||
chmod 0711 "$MOUNT_ROOT"
|
||||
|
||||
chown 1000:1000 "$USERS_DIR"
|
||||
with_vm_lock prepare_caller_mounts 2>/dev/null && fail "root repaired a caller-owned mount boundary instead of rejecting it"
|
||||
mounts_ready 2>/dev/null && fail "final guard accepted a caller-owned mount boundary"
|
||||
[[ $(command stat -Lc '%u' "$USERS_DIR") == 1000 ]] || fail "rejection unexpectedly changed the boundary owner"
|
||||
chown root:root "$USERS_DIR"
|
||||
|
||||
[[ $(mount_layer_count "$EXPECTED_STORAGE") == 1 &&
|
||||
$(mount_layer_count "$EXPECTED_SHARED") == 1 ]] || fail "boundary rejection changed the verified mount pair"
|
||||
mounts_ready || fail "restored production boundaries were rejected"
|
||||
pass "root rejects wrong-owned and group-writable production mount boundaries without mutation"
|
||||
|
||||
expected_space=$(command df -P -- /home/storage-target | awk 'NR==2 {print int($4/1024/1024)}')
|
||||
actual_space=$(available_storage_gb)
|
||||
[[ $actual_space == "$expected_space" ]] || fail "disk-space helper did not measure the storage target filesystem"
|
||||
[[ $(command df -P -- /home/alice | awk 'NR==2 {print int($4/1024/1024)}') != "$actual_space" ]] || fail "test filesystems do not distinguish home from storage"
|
||||
pass "disk-space accounting measures the actual storage filesystem, not home"
|
||||
|
||||
# Exercise the real root writer and final guard against the production paths.
|
||||
printf 'RAM=4G\nCORES=2\nDISK=64G\nUSERNAME=alice\nPASSWORD=pw\nTZ=UTC\n' |
|
||||
with_vm_lock __priv_write_compose
|
||||
[[ $(command stat -Lc '%u:%a' "$COMPOSE_FILE") == 0:640 ]] || fail "root compose ownership/mode is wrong"
|
||||
with_vm_lock assert_mounts_safe || fail "final root mount/compose assertion rejected the verified pair"
|
||||
pass "root writer and final pre-Docker guard revalidate the pinned production mounts"
|
||||
|
||||
# Upgrade the exact sibling-anchor pair emitted by the earlier fix without
|
||||
# moving or replacing either familiar home symlink.
|
||||
sed -i "s|$EXPECTED_STORAGE:/storage|$OLD_EXPECTED_STORAGE:/storage|" "$COMPOSE_FILE"
|
||||
sed -i "s|$EXPECTED_SHARED:/shared|$OLD_EXPECTED_SHARED:/shared|" "$COMPOSE_FILE"
|
||||
sed -i '/PROTECT: "Y"/d' "$COMPOSE_FILE"
|
||||
compose_needs_security_migration || fail "previous protected compose was not recognized for upgrade"
|
||||
with_vm_lock assert_mounts_safe || fail "root could not upgrade previous protected anchors"
|
||||
grep -q -- "- $EXPECTED_STORAGE:/storage" "$COMPOSE_FILE" || fail "upgrade did not rewrite storage anchor"
|
||||
grep -q -- "- $EXPECTED_SHARED:/shared" "$COMPOSE_FILE" || fail "upgrade did not rewrite shared anchor"
|
||||
grep -q 'PROTECT: "Y"' "$COMPOSE_FILE" || fail "upgrade did not protect the web console"
|
||||
[[ $(readlink /home/alice/.windows) == /home/storage-target ]] || fail "protected-anchor upgrade replaced home storage link"
|
||||
pass "previous sibling-anchor installs upgrade in place to the fixed /var/lib boundary"
|
||||
|
||||
# A compose that already uses the fixed anchors still needs an authorized
|
||||
# upgrade when it predates web-console authentication.
|
||||
sed -i '/PROTECT: "Y"/d' "$COMPOSE_FILE"
|
||||
compose_needs_security_migration || fail "unprotected fixed-anchor compose was not recognized for upgrade"
|
||||
with_vm_lock assert_mounts_safe || fail "root could not protect an existing fixed-anchor compose"
|
||||
grep -q 'PROTECT: "Y"' "$COMPOSE_FILE" || fail "fixed-anchor upgrade did not protect the web console"
|
||||
pass "existing fixed-anchor compose gains web-console authentication"
|
||||
|
||||
# Preflight both sources before either bind on a clean anchor pair.
|
||||
umount "$EXPECTED_SHARED"
|
||||
umount "$EXPECTED_STORAGE"
|
||||
rm /home/alice/Windows
|
||||
ln -s / /home/alice/Windows
|
||||
chown -h 1000:1000 /home/alice/Windows
|
||||
with_vm_lock prepare_caller_mounts 2>/dev/null && fail "root accepted a non-caller-owned second source"
|
||||
[[ $(mount_layer_count "$EXPECTED_STORAGE") == 0 && $(mount_layer_count "$EXPECTED_SHARED") == 0 ]] || fail "failed second-source preflight left a partial bind"
|
||||
[[ $(readlink /home/alice/Windows) == / ]] || fail "failed preflight consumed or quarantined symlink"
|
||||
pass "root preflights both sources before mounting either and preserves rejection evidence"
|
||||
|
||||
# mountpoint(1) follows symlinks, so explicitly pin the invariant that even a
|
||||
# root-planted anchor symlink to the expected mounted source is rejected.
|
||||
rm /home/alice/Windows
|
||||
ln -s /home/shared-target /home/alice/Windows
|
||||
chown -h 1000:1000 /home/alice/Windows
|
||||
rmdir "$EXPECTED_STORAGE"
|
||||
ln -s /home/storage-target "$EXPECTED_STORAGE"
|
||||
storage_id=$(command stat -Lc '%d:%i' /home/storage-target)
|
||||
mounted_leaf_matches "$EXPECTED_STORAGE" "$storage_id" && fail "symlink mount anchor passed final identity check"
|
||||
with_vm_lock prepare_caller_mounts 2>/dev/null && fail "root followed a symlink mount anchor"
|
||||
[[ -L $EXPECTED_STORAGE ]] || fail "rejected anchor symlink was consumed"
|
||||
pass "final guard rejects a symlink even when it resolves to the expected mounted source"
|
||||
Reference in New Issue
Block a user