From f37c73fa2028f54958779ad5320a07775a9f2a68 Mon Sep 17 00:00:00 2001 From: Afonso Oliveira Date: Mon, 7 Sep 2026 17:27:04 +0100 Subject: [PATCH] Bind protected update commands to their source root --- bin/omarchy-refresh-pacman | 1 + bin/omarchy-security-functions | 14 +++++ bin/omarchy-update | 1 + bin/omarchy-update-stay-awake | 1 + docs/update-process.md | 3 +- test/shell.d/security-source-root-test.sh | 65 +++++++++++++++++++++++ 6 files changed, 84 insertions(+), 1 deletion(-) create mode 100755 test/shell.d/security-source-root-test.sh diff --git a/bin/omarchy-refresh-pacman b/bin/omarchy-refresh-pacman index af552abb..de6ca405 100755 --- a/bin/omarchy-refresh-pacman +++ b/bin/omarchy-refresh-pacman @@ -12,6 +12,7 @@ source "${BASH_SOURCE[0]%/*}/omarchy-security-functions" || exit 126 omarchy_security_require_privileged_bash_startup || exit 126 set -e omarchy_security_sanitize_bash_environment "$0" "$@" +omarchy_security_require_source_root "$0" user_path=$PATH omarchy_security_revoke_sudo_timestamp || exit 1 omarchy_security_install_sudo_cleanup_traps diff --git a/bin/omarchy-security-functions b/bin/omarchy-security-functions index 5c9bd9e4..703fd630 100644 --- a/bin/omarchy-security-functions +++ b/bin/omarchy-security-functions @@ -44,6 +44,20 @@ omarchy_security_sanitize_bash_environment() { fi } +omarchy_security_require_source_root() { + local command_source command_name=${1##*/} + command_source=$(/usr/bin/readlink -e -- "$1") || return 1 + + # A runtime root selects the code used by this invocation. Accept the + # canonical checkout containing the entrypoint or the package's bin links. + if [[ $OMARCHY_PATH != /* || $(/usr/bin/realpath -e -- "$OMARCHY_PATH") != "$OMARCHY_PATH" ]] || + ! { [[ $command_source == "$OMARCHY_PATH/bin/$command_name" ]] || + [[ $OMARCHY_PATH == "/usr/share/omarchy" && $command_source == "/usr/bin/$command_name" ]]; }; then + echo "OMARCHY_PATH does not match this Omarchy command." >&2 + return 1 + fi +} + omarchy_security_sudo_supports_no_update() { local help help=$(LC_ALL=C /usr/bin/sudo -h 2>&1) || return 1 diff --git a/bin/omarchy-update b/bin/omarchy-update index 9a809351..2026e5e9 100755 --- a/bin/omarchy-update +++ b/bin/omarchy-update @@ -14,6 +14,7 @@ source "${BASH_SOURCE[0]%/*}/omarchy-security-functions" || exit 126 omarchy_security_require_privileged_bash_startup || exit 126 set -e omarchy_security_sanitize_bash_environment "$0" "$@" +omarchy_security_require_source_root "$0" # Logging and lock acquisition re-exec this command with a sanitized PATH. # Preserve the caller's path only for the later unprivileged hook/mise phases. user_path=${OMARCHY_UPDATE_USER_PATH:-$PATH} diff --git a/bin/omarchy-update-stay-awake b/bin/omarchy-update-stay-awake index ff311561..4cb0af52 100755 --- a/bin/omarchy-update-stay-awake +++ b/bin/omarchy-update-stay-awake @@ -13,6 +13,7 @@ source "${BASH_SOURCE[0]%/*}/omarchy-security-functions" || exit 126 omarchy_security_require_privileged_bash_startup || exit 126 set -e omarchy_security_sanitize_bash_environment "$0" "$@" +omarchy_security_require_source_root "$0" omarchy_security_revoke_sudo_timestamp || exit 1 omarchy_security_install_sudo_cleanup_traps omarchy_security_enable_no_update_sudo diff --git a/docs/update-process.md b/docs/update-process.md index 52a8b423..ab073e83 100644 --- a/docs/update-process.md +++ b/docs/update-process.md @@ -144,11 +144,12 @@ omarchy-update Important behavior: -- `omarchy update` uses the session’s `OMARCHY_PATH` and a fixed command search path for its system phases. User PATH is restored behind the sudo wrapper for hooks and mise. +- Protected update entrypoints require the session's canonical `OMARCHY_PATH` to match their own checkout or the packaged `/usr/bin` entrypoint before selecting commands or the sudo wrapper. This preserves intentionally trusted development checkouts while rejecting a command paired with a different source root. System phases use a fixed command search path; user PATH is restored behind the sudo wrapper for hooks and mise. - Mixed-trust update entrypoints start Bash in privileged mode, discard `BASH_ENV`, `ENV`, and exported-function records before launching helpers, and reject an ordinary `bash path/to/command` invocation. Run them as executables (normally through the `omarchy` CLI); `/usr/bin/bash -p path/to/command` is the explicit interpreter form. This keeps shell startup injection from replacing the no-update sudo boundary. - In dev-link mode, `omarchy update` fast-forwards the active checkout from its configured upstream before changing system packages or running migrations. - Migrations remain in chronological order even though historical entries mix user-controlled code with later privileged repairs. Before entering that mixed-trust tail, Omarchy invalidates its timestamp and forces every later sudo call—including AUR's configurable sudo command—to use `--no-update`; prompts authorize one command without publishing a reusable timestamp. Yay's credential loop is disabled for the update. - User-controlled post-update hooks and mise tools run only after every sudo-capable update stage. Omarchy invalidates its sudo timestamp before each boundary and on every exit; detached children therefore have no later reusable update authorization to wait for. +- This lifecycle controls authorization created by the protected workflow. `sudo -N` prevents cache updates but can use an existing valid credential, and `sudo -k` revokes the current session's timestamp. It does not isolate the account from unrelated concurrent authentication in another workflow. - `-y` exports `OMARCHY_UPDATE_UNATTENDED=1` and suppresses Omarchy confirmation prompts. Interactive review steps (orphan removal, conflict handoff) report and skip instead of blocking. Privileged commands still require sudo authorization, and command-scoped authentication can prompt separately for each command. - The free-space requirement uses a 10 GiB threshold and stops the update before confirmation when it is not met. If free space cannot be determined, the diff --git a/test/shell.d/security-source-root-test.sh b/test/shell.d/security-source-root-test.sh new file mode 100755 index 00000000..c872e78d --- /dev/null +++ b/test/shell.d/security-source-root-test.sh @@ -0,0 +1,65 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" +source "$SHELL_TEST_DIR/fixtures/sudo-boundary-test.sh" +source "$SUDO_TEST_ROOT/bin/omarchy-security-functions" + +rm -f "$SUDO_TEST_ROOT/bin/omarchy-update" +copy_boundary_file bin/omarchy-update +omarchy_security_require_source_root "$SUDO_TEST_ROOT/bin/omarchy-update" || fail "matching checkout root was rejected" +pass "a canonical checkout matches its own entrypoint" + +mkdir "$boundary_tmp/other-root" +ln -s "$SUDO_TEST_ROOT" "$boundary_tmp/root-link" +for root in "$boundary_tmp/other-root" "$boundary_tmp/root-link" .; do + if OMARCHY_PATH="$root" omarchy_security_require_source_root "$SUDO_TEST_ROOT/bin/omarchy-update" >"$boundary_tmp/output" 2>&1; then + fail "a different or noncanonical source root was accepted" + fi +done +pass "different, symlink and relative roots are rejected" + +# Redirect only the two package-layout literals into the fixture. Resolution +# still uses real readlink/realpath; no host /usr/bin file is changed or run. +package_root="$boundary_tmp/usr/share/omarchy" +package_bin="$boundary_tmp/usr/bin" +mkdir -p "$package_root/bin" "$package_bin" +cp "$SUDO_TEST_ROOT/bin/omarchy-update" "$package_bin/omarchy-update" +cp "$SUDO_TEST_ROOT/bin/omarchy-update" "$package_bin/different-command" +ln -s "$package_bin/omarchy-update" "$package_root/bin/omarchy-update" +python3 - "$SUDO_TEST_ROOT/bin/omarchy-security-functions" "$boundary_tmp/package-library" "$package_root" "$package_bin" <<'PY' +import sys +from pathlib import Path +source, output, root, binaries = sys.argv[1:] +text = Path(source).read_text() +text = text.replace('"/usr/share/omarchy"', f'"{root}"') +text = text.replace('"/usr/bin/$command_name"', f'"{binaries}/$command_name"') +Path(output).write_text(text) +PY +source "$boundary_tmp/package-library" +OMARCHY_PATH="$package_root" omarchy_security_require_source_root "$package_bin/omarchy-update" || fail "package binary was rejected" +OMARCHY_PATH="$package_root" omarchy_security_require_source_root "$package_root/bin/omarchy-update" || fail "package link was rejected" +pass "the package binary and its matching source-tree link are accepted" + +ln -sfn "$package_bin/different-command" "$package_root/bin/omarchy-update" +if OMARCHY_PATH="$package_root" omarchy_security_require_source_root "$package_root/bin/omarchy-update" >"$boundary_tmp/output" 2>&1; then + fail "a package link to a different command was accepted" +fi +pass "a package link must resolve to its named command" + +# Run the protected entrypoints themselves with a mismatched root. These must +# stop before any sudo or operational fixture command, not merely validate in +# an isolated library test. +for command in omarchy-update omarchy-refresh-pacman omarchy-update-stay-awake; do + rm -f "$SUDO_TEST_ROOT/bin/$command" + copy_boundary_file "bin/$command" + for root in "$boundary_tmp/other-root" .; do + reset_boundary + if OMARCHY_PATH="$root" "$SUDO_TEST_ROOT/bin/$command" >"$boundary_tmp/output" 2>&1; then + fail "$command accepted a mismatched root" + fi + [[ ! -s $SUDO_TEST_LOG ]] || fail "$command ran work before rejecting its root" + done + pass "$command rejects mismatched and relative roots before work" +done