From c6d676f23c459b36a96d9673aa18b4a0efa7aa42 Mon Sep 17 00:00:00 2001 From: Mehmet INCE <4004716+mdisec@users.noreply.github.com> Date: Tue, 25 Aug 2026 08:37:55 +0100 Subject: [PATCH] Pin trusted PATH in privileged DNS helper (backport of #8172) Backport of the DNS PATH pin (PR #8172 by @mdisec, merged to quattro as 4637735a) onto the v4-0-1 release branch. omarchy dev link prepends a user-writable checkout's bin/ to sudo's secure_path so privileged Omarchy commands resolve to the development versions, and that reaches the subprocesses they launch too. This branch carries the same passwordless grant -- etc/sudoers.d/omarchy-dns lets wheel run /usr/bin/omarchy-dns Cloudflare, Google and DHCP without a password -- so the packaged script ran as root while resolving bare helpers (dirname, install, tee, rm, nmcli, systemctl, awk) through the caller's secure_path. Write access to a dev checkout became arbitrary root execution, with no password prompt in the way. Pin PATH to trusted system directories once EUID is 0. The restriction lands only after elevation, so the unprivileged wrapper phase keeps the caller's PATH and can still find sudo or pkexec; every helper the privileged half uses is a system utility, so it needs nothing from the checkout. Clean cherry-pick: both files are byte-identical to quattro, so merging v4-0-1 into quattro resolves without a conflict. Verified by mutation: with the pin removed, test/shell.d/dns-sudoers-test.sh fails at the poisoned-helper case; restored, all four of its cases pass. test/shell (189 files) and test/cli pass. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018DEMYa9UWtroz93DhMTtcV --- bin/omarchy-dns | 12 +++++++ test/shell.d/dns-sudoers-test.sh | 54 ++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/bin/omarchy-dns b/bin/omarchy-dns index a22e028c..60f60e74 100755 --- a/bin/omarchy-dns +++ b/bin/omarchy-dns @@ -6,6 +6,18 @@ set -euo pipefail +# Whenever this runs as root — invoked directly through the passwordless +# sudoers rule, or re-execed by require_root below — sudo's secure_path decides +# where a bare helper resolves, and a dev link (etc/sudoers.d/omarchy-dev-path) +# prepends a user-writable checkout bin/ to it. Every helper this script calls +# by bare name (dirname, install, tee, rm, nmcli, systemctl, awk) is a system +# tool, never an omarchy-* command, so pin PATH to trusted system directories +# and keep root from resolving one out of that checkout. The unprivileged +# wrapper phase keeps the caller's PATH so it can still find sudo/pkexec. +if (( EUID == 0 )); then + export PATH=/usr/local/sbin:/usr/local/bin:/usr/bin:/usr/sbin:/bin:/sbin +fi + NM_DNS_CONF=/etc/NetworkManager/conf.d/20-omarchy-dns.conf provider_from_arg() { diff --git a/test/shell.d/dns-sudoers-test.sh b/test/shell.d/dns-sudoers-test.sh index e8424e4a..e5390f46 100755 --- a/test/shell.d/dns-sudoers-test.sh +++ b/test/shell.d/dns-sudoers-test.sh @@ -30,6 +30,60 @@ grep -E 'sudo -n -l -l' "$dns" >/dev/null || pass "dns sudoers rule is scoped to the stock providers" +# The privileged half runs as root under sudo's secure_path, and a dev link +# (etc/sudoers.d/omarchy-dev-path) prepends a user-writable checkout bin/ to it. +# Every helper the script calls by bare name -- dirname, install, tee, nmcli, +# systemctl, awk -- is a system tool, so once it holds root the script pins PATH +# to trusted system directories and never resolves one of them out of the +# checkout. The unprivileged wrapper phase keeps the caller's PATH, which is why +# the pin is gated on EUID rather than set unconditionally. +grep -Eq '^\s*export PATH=/usr/local/sbin:/usr/local/bin:/usr/bin' "$dns" || + fail "omarchy-dns pins PATH to trusted system directories when it holds root" +# require_root carries its own `(( EUID == 0 ))`, so matching that text alone +# would pass with the pin deleted. Anchor on the unindented guard and require the +# pin to be the line it opens. +gated=$(grep -A1 -E '^if \(\( EUID == 0 \)\); then$' "$dns" || true) +[[ $gated == *"export PATH=/usr/local/sbin:/usr/local/bin:/usr/bin"* ]] || + fail "omarchy-dns gates the trusted-PATH pin on holding root" + +# The no-argument path only reads DNS config, so exercise the privileged phase +# directly when the suite is root and as namespaced root otherwise. This reaches +# tr while EUID is 0 without giving an ordinary test run any host privileges. +root_runner=() +if (( EUID != 0 )); then + root_runner=(unshare --user --map-root-user) +fi + +# A sandbox or a hardened kernel can refuse unprivileged user namespaces, and +# the non-graphical suites have to stay green on any machine -- a skip is a +# passing test. Only the runtime probe needs the namespace; the static checks +# above and the elevation checks below run either way. +if (( EUID == 0 )) || unshare --user --map-root-user true 2>/dev/null; then + poison_dir=$(mktemp -d) + poison_ran="$poison_dir/ran" + for helper in tr awk dirname install tee; do + cat >"$poison_dir/$helper" <"$poison_ran" +exec "/usr/bin/$helper" "\$@" +SH + chmod +x "$poison_dir/$helper" + done + + if ! PATH="$poison_dir:$PATH" "${root_runner[@]}" bash "$dns" /dev/null 2>&1; then + rm -rf "$poison_dir" + fail "root omarchy-dns failed its read-only trusted-PATH probe" + fi + if [[ -e $poison_ran ]]; then + rm -rf "$poison_dir" + fail "root omarchy-dns resolved a bare helper from the front of PATH instead of a trusted system path" + fi + rm -rf "$poison_dir" + pass "root omarchy-dns resolves system helpers from a trusted PATH, not the invocation PATH" +else + pass "no unprivileged user namespace; skipping the root trusted-PATH probe" +fi + # require_root returns immediately for root, so the stubs below would not stand # between the script and the host's real NetworkManager and resolved config. if (( EUID == 0 )); then