From 5c336885d22f97e4cb00b2512eea5d3dba74f38e Mon Sep 17 00:00:00 2001 From: Mehmet Ince Date: Thu, 27 Aug 2026 17:43:39 +0100 Subject: [PATCH] Harden CUPS printer discovery Run cups-browsed as a locked service account with a dedicated cache and a focused systemd sandbox. Restrict automatic queues to driverless IPP printers, remove wheel from passwordless CUPS administration, replace cups-pdf with Polkit-backed setup, and migrate existing systems safely. Reported-By: Erik Hunstad (Bad Sector Labs) Co-Authored-By: Daybreak Blue --- etc/cups/cups-browsed.conf | 12 +- .../cups-browsed.service.d/10-omarchy.conf | 11 ++ etc/sysusers.d/omarchy-cups-browsed.conf | 1 + install/config/all.sh | 1 + install/config/printing.sh | 83 +++++++++ install/omarchy-base.packages | 2 +- migrations/1787815267.sh | 34 ++++ test/acceptance.d/system-test.sh | 68 ++++++- test/shell.d/cups-hardening-test.sh | 169 ++++++++++++++++++ 9 files changed, 375 insertions(+), 6 deletions(-) create mode 100644 etc/systemd/system/cups-browsed.service.d/10-omarchy.conf create mode 100644 etc/sysusers.d/omarchy-cups-browsed.conf create mode 100644 install/config/printing.sh create mode 100644 migrations/1787815267.sh create mode 100644 test/shell.d/cups-hardening-test.sh diff --git a/etc/cups/cups-browsed.conf b/etc/cups/cups-browsed.conf index ed1bdbad..19863e59 100644 --- a/etc/cups/cups-browsed.conf +++ b/etc/cups/cups-browsed.conf @@ -1,4 +1,8 @@ -# Omarchy override of cups-browsed's shipped config. The only behavioural -# change vs the upstream default (all-commented) is enabling auto-registration -# of remote IPP printers discovered via Avahi/mDNS. -CreateRemotePrinters Yes +# Keep state away from /var/cache/cups, which is writable by the account CUPS +# uses for print filters. cups-browsed is the only writer to this directory. +CacheDir /var/cache/cups-browsed + +# Auto-create queues only for modern driverless IPP printers. Remote queues +# exported by another CUPS server can still be added manually when needed. +CreateIPPPrinterQueues Driverless +CreateRemoteCUPSPrinterQueues No diff --git a/etc/systemd/system/cups-browsed.service.d/10-omarchy.conf b/etc/systemd/system/cups-browsed.service.d/10-omarchy.conf new file mode 100644 index 00000000..54107197 --- /dev/null +++ b/etc/systemd/system/cups-browsed.service.d/10-omarchy.conf @@ -0,0 +1,11 @@ +[Service] +User=cups-browsed +Group=cups-browsed +CacheDirectory=cups-browsed +CacheDirectoryMode=0750 +UMask=0027 +NoNewPrivileges=yes +ProtectSystem=strict +ProtectHome=yes +PrivateTmp=yes +RestrictSUIDSGID=yes diff --git a/etc/sysusers.d/omarchy-cups-browsed.conf b/etc/sysusers.d/omarchy-cups-browsed.conf new file mode 100644 index 00000000..fa602c18 --- /dev/null +++ b/etc/sysusers.d/omarchy-cups-browsed.conf @@ -0,0 +1 @@ +u cups-browsed - "CUPS printer discovery" / - diff --git a/install/config/all.sh b/install/config/all.sh index 91256dc7..aa1044f1 100644 --- a/install/config/all.sh +++ b/install/config/all.sh @@ -7,5 +7,6 @@ run_logged "$OMARCHY_INSTALL/config/ssh-keepalive.sh" run_logged "$OMARCHY_INSTALL/config/docker.sh" run_logged "$OMARCHY_INSTALL/config/snapper.sh" run_logged "$OMARCHY_INSTALL/config/locate.sh" +run_logged "$OMARCHY_INSTALL/config/printing.sh" run_logged "$OMARCHY_INSTALL/config/enable-services.sh" run_logged "$OMARCHY_INSTALL/config/firewall.sh" diff --git a/install/config/printing.sh b/install/config/printing.sh new file mode 100644 index 00000000..b99e03c3 --- /dev/null +++ b/install/config/printing.sh @@ -0,0 +1,83 @@ +# cups-browsed manages queues through CUPS and does not need Unix root. Give +# only its locked service account passwordless CUPS administration; interactive +# users go through cups-pk-helper and Polkit instead. +cups_files_conf="${OMARCHY_CUPS_FILES_CONF:-/etc/cups/cups-files.conf}" +cups_browsed_sysusers_conf="${OMARCHY_CUPS_BROWSED_SYSUSERS_CONF:-/etc/sysusers.d/omarchy-cups-browsed.conf}" + +if [[ -f $cups_browsed_sysusers_conf ]]; then + systemd-sysusers "$cups_browsed_sysusers_conf" +fi + +if [[ -L $cups_files_conf ]]; then + echo "Refusing to rewrite symlinked CUPS authorization config: $cups_files_conf" >&2 + false +elif [[ -f $cups_files_conf ]]; then + staged_conf=$(mktemp --tmpdir="${cups_files_conf%/*}" ".${cups_files_conf##*/}.XXXXXX") + + if ! awk ' + NR == FNR { + if ($1 == "SystemGroup") { + for (i = 2; i <= NF; i++) { + if (substr($i, 1, 1) == "#") + break + if ($i != "wheel" && !seen_group[$i]) { + system_groups[++system_group_count] = $i + seen_group[$i] = 1 + } + } + } + next + } + + $1 == "SystemGroup" { + comment_start = index($0, "#") + if (!wrote_system_group) { + printf "SystemGroup" + for (i = 1; i <= system_group_count; i++) + printf " %s", system_groups[i] + if (!seen_group["cups-browsed"]) + printf " cups-browsed" + if (comment_start) + printf " %s", substr($0, comment_start) + print "" + wrote_system_group = 1 + } else if (comment_start) { + print substr($0, comment_start) + } + next + } + + $1 == "PeerCred" { + comment_start = index($0, "#") + if (!saw_peer_cred) { + printf "PeerCred on" + if (comment_start) + printf " %s", substr($0, comment_start) + print "" + } else if (comment_start) { + print substr($0, comment_start) + } + saw_peer_cred = 1 + next + } + + { print } + + END { + if (!wrote_system_group) + print "SystemGroup sys root cups-browsed" + if (!saw_peer_cred) + print "PeerCred on" + } + ' "$cups_files_conf" "$cups_files_conf" >"$staged_conf"; then + rm -f "$staged_conf" + false + fi + + if ! chmod --reference="$cups_files_conf" "$staged_conf" || + ! chown --reference="$cups_files_conf" "$staged_conf" || + ! mv -f "$staged_conf" "$cups_files_conf"; then + rm -f "$staged_conf" + false + fi +fi diff --git a/install/omarchy-base.packages b/install/omarchy-base.packages index 4097b645..f4c5e2ef 100644 --- a/install/omarchy-base.packages +++ b/install/omarchy-base.packages @@ -19,7 +19,7 @@ cliamp cups cups-browsed cups-filters -cups-pdf +cups-pk-helper ddcutil docker docker-buildx diff --git a/migrations/1787815267.sh b/migrations/1787815267.sh new file mode 100644 index 00000000..c5b89c2f --- /dev/null +++ b/migrations/1787815267.sh @@ -0,0 +1,34 @@ +echo "Separate printer discovery from root and print-filter access" + +machine_marker="${OMARCHY_CUPS_MIGRATION_MARKER:-/var/lib/omarchy/migrations/1787815267}" + +[[ ! -e $machine_marker ]] || exit 0 + +# CUPS-PDF accepts a job-controlled post-processing command in a backend that +# CUPS launches as root. Native application print-to-file support replaces it. +omarchy-pkg-drop cups-pdf + +# system-config-printer uses this helper to request printer administration +# through Polkit now that the desktop user's wheel group is no longer @SYSTEM. +if omarchy-pkg-present cups; then + omarchy-pkg-add cups-pk-helper +fi + +cups_browsed_was_active=0 +if systemctl is-active --quiet cups-browsed.service 2>/dev/null; then + cups_browsed_was_active=1 + sudo systemctl stop cups-browsed.service +fi + +if omarchy-pkg-present cups; then + sudo env OMARCHY_PATH="$OMARCHY_PATH" \ + bash -euo pipefail "$OMARCHY_PATH/install/config/printing.sh" + sudo systemctl daemon-reload + sudo systemctl try-reload-or-restart cups.service +fi + +if (( cups_browsed_was_active )) && omarchy-pkg-present cups-browsed; then + sudo systemctl restart cups-browsed.service +fi + +sudo install -Dm644 /dev/null "$machine_marker" diff --git a/test/acceptance.d/system-test.sh b/test/acceptance.d/system-test.sh index 53b2c1d7..c5c8da5c 100644 --- a/test/acceptance.d/system-test.sh +++ b/test/acceptance.d/system-test.sh @@ -64,6 +64,72 @@ verify_services() { pass "user audio services are running" } +verify_printing_security() { + local cups_browsed_pid lpinfo_output printer_name printer_process printer_tmp + + ! pacman -Q cups-pdf >/dev/null 2>&1 || fail "CUPS-PDF is absent" + pass "the root CUPS-PDF backend is not installed" + + getent passwd cups-browsed >/dev/null || fail "the cups-browsed service account exists" + [[ $(systemctl show -P User cups-browsed.service) == "cups-browsed" ]] || + fail "cups-browsed runs as its service account" + [[ $(systemctl show -P Group cups-browsed.service) == "cups-browsed" ]] || + fail "cups-browsed runs as its service group" + systemctl is-active --quiet cups-browsed.service || fail "cups-browsed is running" + + cups_browsed_pid=$(systemctl show -P MainPID cups-browsed.service) + [[ -r /proc/$cups_browsed_pid/status ]] || fail "cups-browsed has a readable process status" + [[ $(awk '/^Uid:/{print $2}' "/proc/$cups_browsed_pid/status") != 0 ]] || + fail "cups-browsed does not run with root UID" + [[ $(awk '/^CapEff:/{print $2}' "/proc/$cups_browsed_pid/status") == "0000000000000000" ]] || + fail "cups-browsed has no effective Linux capabilities" + + [[ $(stat -c '%a %U:%G' /var/cache/cups-browsed) == "750 cups-browsed:cups-browsed" ]] || + fail "cups-browsed has an isolated cache" "$(stat -c '%a %U:%G' /var/cache/cups-browsed)" + ! id -nG cups-browsed | grep -qw cups || + fail "cups-browsed is separate from the print-filter group" + + if lpinfo_output=$(LC_ALL=C timeout 10 lpinfo -v &1); then + fail "the desktop user cannot administer CUPS without authentication" + elif [[ $lpinfo_output != *"Forbidden"* ]]; then + fail "CUPS explicitly denies unauthenticated desktop administration" "$lpinfo_output" + fi + + pass "CUPS discovery is isolated from root, filters, and passwordless desktop administration" + + # A live driverless printer proves the non-root daemon can still discover and + # create queues without the CAP_NET_BIND_SERVICE Ubuntu carries downstream. + printer_name="OmarchyAcceptancePrinter" + printer_tmp=$(mktemp -d) + printf '#!/bin/bash\nexit 0\n' >"$printer_tmp/command" + chmod 0700 "$printer_tmp/command" + mkdir -m 0700 "$printer_tmp/spool" + + ippeveprinter -p 18631 -d "$printer_tmp/spool" -c "$printer_tmp/command" "$printer_name" \ + >"$printer_tmp/ippeveprinter.log" 2>&1 & + printer_process=$! + + printing_test_cleanup() { + kill "$printer_process" >/dev/null 2>&1 || true + wait "$printer_process" >/dev/null 2>&1 || true + rm -rf "$printer_tmp" + } + trap printing_test_cleanup EXIT + + for _ in {1..30}; do + lpstat -v "$printer_name" 2>/dev/null | grep -q "implicitclass://$printer_name/" && break + sleep 1 + done + + lpstat -v "$printer_name" 2>/dev/null | grep -q "implicitclass://$printer_name/" || + fail "non-root cups-browsed discovers a driverless IPP printer" "$(<"$printer_tmp/ippeveprinter.log")" + + printing_test_cleanup + trap - EXIT + + pass "non-root cups-browsed still creates driverless IPP queues without capabilities" +} + verify_runtime_tools() { # Docker access is intentionally NOT granted to the desktop user: the docker # group is root-equivalent, so a rogue process running as the user could @@ -107,7 +173,7 @@ verify_user_setup() { pass "Omarchy user state and shell configuration exist" } -for check in verify_core_packages verify_defaults verify_services verify_runtime_tools verify_user_setup; do +for check in verify_core_packages verify_defaults verify_services verify_printing_security verify_runtime_tools verify_user_setup; do if ! ("$check"); then status=1 fi diff --git a/test/shell.d/cups-hardening-test.sh b/test/shell.d/cups-hardening-test.sh new file mode 100644 index 00000000..7ba8fea3 --- /dev/null +++ b/test/shell.d/cups-hardening-test.sh @@ -0,0 +1,169 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +packages="$ROOT/install/omarchy-base.packages" +cups_browsed_conf="$ROOT/etc/cups/cups-browsed.conf" +sysusers_conf="$ROOT/etc/sysusers.d/omarchy-cups-browsed.conf" +service_dropin="$ROOT/etc/systemd/system/cups-browsed.service.d/10-omarchy.conf" + +grep -qxF cups-browsed "$packages" || fail "cups-browsed remains in the base package set" +grep -qxF cups-pk-helper "$packages" || fail "Polkit printer administration is installed" +! grep -qxF cups-pdf "$packages" || fail "the root CUPS-PDF backend is removed" + +pass "the base install keeps discovery and replaces CUPS-PDF with Polkit administration" + +grep -qxF 'CacheDir /var/cache/cups-browsed' "$cups_browsed_conf" || + fail "cups-browsed keeps state outside the print-filter cache" +grep -qxF 'CreateIPPPrinterQueues Driverless' "$cups_browsed_conf" || + fail "automatic queues are limited to driverless IPP printers" +grep -qxF 'CreateRemoteCUPSPrinterQueues No' "$cups_browsed_conf" || + fail "remote CUPS queues are not created automatically" +! grep -q 'CreateRemotePrinters' "$cups_browsed_conf" || + fail "the unsupported CreateRemotePrinters directive is gone" + +pass "cups-browsed uses explicit supported discovery policy and an isolated cache" + +grep -qxF 'u cups-browsed - "CUPS printer discovery" / -' "$sysusers_conf" || + fail "a locked cups-browsed system account is declared" + +for setting in \ + 'User=cups-browsed' \ + 'Group=cups-browsed' \ + 'CacheDirectory=cups-browsed' \ + 'CacheDirectoryMode=0750' \ + 'UMask=0027' \ + 'NoNewPrivileges=yes' \ + 'ProtectSystem=strict' \ + 'ProtectHome=yes' \ + 'PrivateTmp=yes' \ + 'RestrictSUIDSGID=yes'; do + grep -qxF "$setting" "$service_dropin" || + fail "cups-browsed service hardening includes $setting" +done + +! grep -q '^\(Ambient\|CapabilityBoundingSet\).*CAP_NET_BIND_SERVICE' "$service_dropin" || + fail "cups-browsed is not granted an unverified network capability" + +pass "cups-browsed runs as its confined service account without added capabilities" + +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT + +mock_bin="$test_tmp/bin" +mkdir -p "$mock_bin" "$test_tmp/etc/cups" "$test_tmp/var/lib/omarchy/migrations" + +cat >"$mock_bin/systemd-sysusers" <<'SH' +#!/bin/bash +printf 'sysusers\t%s\n' "$*" >>"$OMARCHY_CUPS_TEST_LOG" +SH +cat >"$mock_bin/chown" <<'SH' +#!/bin/bash +printf 'chown\t%s\n' "$*" >>"$OMARCHY_CUPS_TEST_LOG" +SH +cat >"$mock_bin/omarchy-pkg-present" <<'SH' +#!/bin/bash +[[ $1 == "cups" || $1 == "cups-browsed" ]] +SH +for command in omarchy-pkg-add omarchy-pkg-drop; do + cat >"$mock_bin/$command" <<'SH' +#!/bin/bash +printf '%s\t%s\n' "${0##*/}" "$*" >>"$OMARCHY_CUPS_TEST_LOG" +SH +done +cat >"$mock_bin/systemctl" <<'SH' +#!/bin/bash +printf 'systemctl\t%s\n' "$*" >>"$OMARCHY_CUPS_TEST_LOG" +exit 0 +SH +cat >"$mock_bin/sudo" <<'SH' +#!/bin/bash +printf 'sudo\t%s\n' "$*" >>"$OMARCHY_CUPS_TEST_LOG" +exec "$@" +SH +chmod +x "$mock_bin"/* + +authorization_conf="$test_tmp/etc/cups/cups-files.conf" +cat >"$authorization_conf" <<'CONF' +# Keep this custom preamble. +SystemGroup sys root wheel custom-admin wheel # Keep this inline comment. +SystemGroup wheel print-operators # Keep this second inline comment. +PeerCred off # Keep this PeerCred comment. +PeerCred off # Keep this second PeerCred comment. +CONF + +log="$test_tmp/actions.log" +export OMARCHY_CUPS_TEST_LOG="$log" + +run_printing_setup() { + PATH="$mock_bin:$PATH" \ + OMARCHY_CUPS_FILES_CONF="$authorization_conf" \ + OMARCHY_CUPS_BROWSED_SYSUSERS_CONF="$sysusers_conf" \ + bash -euo pipefail "$ROOT/install/config/printing.sh" +} + +run_printing_setup + +grep -qxF 'SystemGroup sys root custom-admin print-operators cups-browsed # Keep this inline comment.' "$authorization_conf" || + fail "printing setup reserves CUPS administration for the service account" +grep -qxF '# Keep this second inline comment.' "$authorization_conf" || + fail "printing setup preserves comments from consolidated SystemGroup directives" +grep -qxF 'PeerCred on # Keep this PeerCred comment.' "$authorization_conf" || + fail "printing setup enables peer credentials for the service account" +grep -qxF '# Keep this second PeerCred comment.' "$authorization_conf" || + fail "printing setup preserves comments from duplicate PeerCred directives" +grep -qxF '# Keep this custom preamble.' "$authorization_conf" || + fail "printing setup preserves unrelated CUPS configuration" +[[ $(grep -c '^SystemGroup ' "$authorization_conf") == 1 ]] || + fail "printing setup emits one SystemGroup directive" + +cp "$authorization_conf" "$test_tmp/first-run.conf" +run_printing_setup +cmp -s "$authorization_conf" "$test_tmp/first-run.conf" || + fail "printing setup is idempotent" + +pass "printing setup narrows CUPS authorization without clobbering other configuration" + +ln -s "$authorization_conf" "$test_tmp/etc/cups/symlinked.conf" +if PATH="$mock_bin:$PATH" \ + OMARCHY_CUPS_FILES_CONF="$test_tmp/etc/cups/symlinked.conf" \ + OMARCHY_CUPS_BROWSED_SYSUSERS_CONF="$sysusers_conf" \ + bash -euo pipefail "$ROOT/install/config/printing.sh" 2>/dev/null; then + fail "printing setup refuses a symlinked authorization file" +fi + +pass "printing setup refuses to rewrite a symlinked privileged configuration" + +marker="$test_tmp/var/lib/omarchy/migrations/1787815267" +PATH="$mock_bin:$PATH" \ + OMARCHY_PATH="$ROOT" \ + OMARCHY_CUPS_FILES_CONF="$authorization_conf" \ + OMARCHY_CUPS_BROWSED_SYSUSERS_CONF="$sysusers_conf" \ + OMARCHY_CUPS_MIGRATION_MARKER="$marker" \ + bash -euo pipefail "$ROOT/migrations/1787815267.sh" + +grep -qxF $'omarchy-pkg-drop\tcups-pdf' "$log" || + fail "the migration removes CUPS-PDF" +grep -qxF $'omarchy-pkg-add\tcups-pk-helper' "$log" || + fail "the migration installs authenticated printer administration" +grep -qxF $'systemctl\tstop cups-browsed.service' "$log" || + fail "the migration stops the root cups-browsed process before reconfiguration" +grep -qxF $'systemctl\tdaemon-reload' "$log" || + fail "the migration reloads the hardened service" +grep -qxF $'systemctl\ttry-reload-or-restart cups.service' "$log" || + fail "the migration applies narrowed CUPS authorization" +grep -qxF $'systemctl\trestart cups-browsed.service' "$log" || + fail "the migration resumes an active cups-browsed service" +[[ -f $marker ]] || fail "the migration records machine-wide completion" + +actions_after_first_run=$(wc -l <"$log") +PATH="$mock_bin:$PATH" \ + OMARCHY_PATH="$ROOT" \ + OMARCHY_CUPS_MIGRATION_MARKER="$marker" \ + bash -euo pipefail "$ROOT/migrations/1787815267.sh" +[[ $(wc -l <"$log") == "$actions_after_first_run" ]] || + fail "the machine-wide migration repeats privileged work" + +pass "the migration safely converts an active existing installation once"