From 9285b19d6a72eba3df8537d62a4cd5506a803d89 Mon Sep 17 00:00:00 2001 From: Adrian Rangel Date: Tue, 25 Aug 2026 03:03:12 -0600 Subject: [PATCH] [Security] Stop USB device names from being executed as Hyprland Lua (#8129) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Stop device names from being executed as Hyprland Lua Hyprland input-device and monitor names come from USB descriptors and hyprctl output, so they are attacker-influenceable, yet the toggle and monitor commands interpolated them straight into hyprctl eval and into generated Lua that Hyprland re-executes on every reload. The input-device toggle keys are bound with locked = true, so a malicious USB name reached Lua code execution from the lock screen; a persisted disable made it run on every start. This closes that class everywhere it appeared. - The touchpad/touchscreen disable is now the device name in a plain-text sidecar file, read back by a packaged Lua module on reload, never a generated Lua file. hyprctl eval Lua-quotes the name and control characters are rejected outright. - Dropped the shipped *-disabled.lua templates so nothing seeds a disabled state to /etc/skel, making the name file the single source of truth read from a hardcoded ~/.local/state to match the sibling tools. - The reload loader excludes those two legacy filenames, so a leftover generated *-disabled.lua on a not-yet-migrated install can never be sourced as code again; a migration then recovers the device name from it and deletes it, sanitizing installs that ran the vulnerable version. - All four monitor scripts (internal, mirror, clamshell, scaling) now validate an output name against a plain-connector-name pattern before writing it as Lua, closing the same latent pattern in the siblings. - paths.lua treats a set-but-empty XDG_STATE_HOME as unset, matching the bash side so state is never read from the filesystem root. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0144ZDt44vtxjyF8j9Y88NrM * Let a failing Lua assertion fail the test lua discards the status of a chunk read from stdin, so a blown assert printed its traceback and still exited 0: the surrounding `set -euo pipefail` never fired and the following `pass` printed `ok`. Every Lua block in these two files was unenforced, including the assertion that a quoted `hyprctl eval` cannot reach `os.execute` and the negative control that proves the test can detect the injection at all. Passing the chunk as a script argument makes lua report the failure. Co-Authored-By: Claude Opus 5 (1M context) * Re-apply a recovered input-device disable to the running session The package hook reloads Hyprland during `omarchy-update-system-pkgs`, before `omarchy-migrate` runs, and at that reload the generated Lua is already excluded while the name file does not exist yet — so a touchpad or touchscreen the user had switched off comes back on, and stays on until their next login. Reload once more once the name has been recovered, which is the same path a login already takes to read it. Co-Authored-By: Claude Opus 5 (1M context) Co-Authored-By: Codex XHigh --------- Co-authored-by: Claude Fable 5 Co-authored-by: Omarchybot Co-authored-by: Codex XHigh --- bin/omarchy-hyprland-monitor-clamshell | 8 + bin/omarchy-hyprland-monitor-internal | 7 + bin/omarchy-hyprland-monitor-internal-mirror | 9 + bin/omarchy-hyprland-monitor-scaling | 8 + bin/omarchy-toggle-input-device | 54 +++- default/hypr/disabled-input-device.lua | 21 ++ default/hypr/paths.lua | 16 +- default/hypr/require_all.lua | 22 +- default/hypr/toggles.lua | 16 +- manual/13-toggles-idle-screensaver.md | 2 +- migrations/1787618700.sh | 39 +++ test/shell.d/hyprland-paths-test.sh | 28 ++ test/shell.d/monitor-output-name-test.sh | 123 ++++++++ test/shell.d/toggle-input-device-test.sh | 286 +++++++++++++++++++ 14 files changed, 612 insertions(+), 27 deletions(-) create mode 100644 default/hypr/disabled-input-device.lua create mode 100644 migrations/1787618700.sh create mode 100644 test/shell.d/hyprland-paths-test.sh create mode 100644 test/shell.d/monitor-output-name-test.sh create mode 100755 test/shell.d/toggle-input-device-test.sh diff --git a/bin/omarchy-hyprland-monitor-clamshell b/bin/omarchy-hyprland-monitor-clamshell index cbd1b33f..f33f23af 100755 --- a/bin/omarchy-hyprland-monitor-clamshell +++ b/bin/omarchy-hyprland-monitor-clamshell @@ -11,6 +11,14 @@ MONITOR_LUA="$HOME/.config/hypr/monitors.lua" INTERNAL=$(omarchy-hyprland-monitor-laptop) +# INTERNAL is written into generated Lua and hyprctl eval/dispatch below, so a +# name that is not a plain connector string could execute on the next reload. +# Names come from hyprctl; a user-created headless output can carry anything. +if [[ -n $INTERNAL && ! $INTERNAL =~ ^[A-Za-z0-9._-]+$ ]]; then + echo "Refusing unsafe internal monitor name" >&2 + exit 1 +fi + valid_scale() { [[ $1 =~ ^[0-9]+([.][0-9]+)?$ ]] } diff --git a/bin/omarchy-hyprland-monitor-internal b/bin/omarchy-hyprland-monitor-internal index 836073ee..c0be60fa 100755 --- a/bin/omarchy-hyprland-monitor-internal +++ b/bin/omarchy-hyprland-monitor-internal @@ -28,6 +28,13 @@ off() { exit 1 fi + # The name is written into generated Lua below, so only a plain connector + # name may pass; anything else could execute on the next reload. + if [[ ! $INTERNAL =~ ^[A-Za-z0-9._-]+$ ]]; then + omarchy-notification-send -g 󰍹 "Refusing unsafe monitor name" + exit 1 + fi + if ! omarchy-hyprland-monitor-external-active; then omarchy-notification-send -g 󰍹 "Can't disable the only active display" exit 1 diff --git a/bin/omarchy-hyprland-monitor-internal-mirror b/bin/omarchy-hyprland-monitor-internal-mirror index d5213d4c..ec48c740 100755 --- a/bin/omarchy-hyprland-monitor-internal-mirror +++ b/bin/omarchy-hyprland-monitor-internal-mirror @@ -22,6 +22,15 @@ on() { exit 1 fi + # Both names are written into generated Lua below, so only plain connector + # names may pass; a user-created headless output can carry any name. + for output in "$INTERNAL" "$EXTERNAL"; do + if [[ ! $output =~ ^[A-Za-z0-9._-]+$ ]]; then + omarchy-notification-send -g 󰍹 "Refusing unsafe monitor name" + exit 1 + fi + done + omarchy-hyprland-toggle $DISABLE_TOGGLE off if omarchy-hyprland-toggle-disabled $TOGGLE; then diff --git a/bin/omarchy-hyprland-monitor-scaling b/bin/omarchy-hyprland-monitor-scaling index 240a1ea5..8de107f4 100755 --- a/bin/omarchy-hyprland-monitor-scaling +++ b/bin/omarchy-hyprland-monitor-scaling @@ -80,6 +80,14 @@ set_scale() { local width="$(echo "$monitor_info" | jq -r '.width')" local height="$(echo "$monitor_info" | jq -r '.height')" local refresh_rate="$(echo "$monitor_info" | jq -r '.refreshRate')" + + # active_monitor is written into the Lua string eval'd below, so only a plain + # connector name may pass; a hostile output name could execute otherwise. + if [[ ! $active_monitor =~ ^[A-Za-z0-9._-]+$ ]]; then + echo "Refusing unsafe monitor name" >&2 + exit 1 + fi + local new_scale="$(clean_scale "$requested_scale" "$width" "$height")" # GTK only honors integer GDK_SCALE values, so persist the nearest whole # factor even when the monitor scale itself is fractional. diff --git a/bin/omarchy-toggle-input-device b/bin/omarchy-toggle-input-device index aea0ca11..77ff084a 100755 --- a/bin/omarchy-toggle-input-device +++ b/bin/omarchy-toggle-input-device @@ -7,44 +7,70 @@ KIND="${1:-}" ACTION="${2:-toggle}" +usage() { + echo "Usage: omarchy-toggle-input-device [on|off|toggle]" >&2 +} + case "$KIND" in touchpad) LABEL="Touchpad" ICON="touchpad" ;; touchscreen) LABEL="Touchscreen" ICON="touch" ;; *) - echo "Usage: omarchy-toggle-input-device [on|off|toggle]" >&2 + usage exit 1 ;; esac -# Hyprland sources this directory on reload, so the disabled state survives restarts -STATE_FILE="$HOME/.local/state/omarchy/toggles/hypr/$KIND-disabled.lua" +# The persisted disable is the device name stored as plain data; on every +# reload default/hypr/disabled-input-device.lua reads it back and disables the +# device. Names come from USB descriptors and must not be interpolated into +# shell or Lua. The path is hardcoded to ~/.local/state like the sibling +# toggle tools, so it keeps working when XDG_STATE_HOME diverges. +NAME_FILE="$HOME/.local/state/omarchy/toggles/hypr/$KIND-disabled-name" device="$("omarchy-hw-$KIND")" -if [[ -z $device ]]; then - echo "No $KIND device found" >&2 - exit 1 -fi +require_device() { + if [[ -z $device ]]; then + echo "No $KIND device found" >&2 + exit 1 + fi + + if [[ $device == *[[:cntrl:]]* ]]; then + echo "Invalid $KIND device name" >&2 + exit 1 + fi +} + +apply_device() { + local enabled=$1 + local quoted=${device//\\/\\\\} + quoted=${quoted//\"/\\\"} + hyprctl eval "hl.device({ name = \"$quoted\", enabled = $enabled })" >/dev/null +} enable() { - hyprctl eval "hl.device({ name = \"$device\", enabled = true })" >/dev/null - rm -f "$STATE_FILE" + # Clear the persisted state before requiring a usable device, so a device + # that stops reporting a valid name can never wedge the disable in place. + rm -f "$NAME_FILE" + require_device + apply_device true omarchy-osd -i "$ICON" -m "$LABEL enabled" } disable() { - hyprctl eval "hl.device({ name = \"$device\", enabled = false })" >/dev/null - mkdir -p "$(dirname "$STATE_FILE")" - printf 'hl.device({ name = "%s", enabled = false })\n' "$device" >"$STATE_FILE" + require_device + apply_device false + mkdir -p "$(dirname "$NAME_FILE")" + printf '%s\n' "$device" >"$NAME_FILE" omarchy-osd -i "$ICON" -m "$LABEL disabled" } case "$ACTION" in on) enable ;; off) disable ;; - toggle) if [[ -f $STATE_FILE ]]; then enable; else disable; fi ;; + toggle) if [[ -f $NAME_FILE ]]; then enable; else disable; fi ;; *) - echo "Usage: omarchy-toggle-input-device [on|off|toggle]" >&2 + usage exit 1 ;; esac diff --git a/default/hypr/disabled-input-device.lua b/default/hypr/disabled-input-device.lua new file mode 100644 index 00000000..c9c20943 --- /dev/null +++ b/default/hypr/disabled-input-device.lua @@ -0,0 +1,21 @@ +-- Disable a Hyprland input device whose name was stored as data, not Lua. +-- Device names come from USB descriptors and must never be loaded as code. + +local paths = require("default.hypr.paths") + +return function(kind) + -- Hardcoded to ~/.local/state to match omarchy-toggle-input-device and the + -- sibling bash toggle tools, which all write there regardless of + -- XDG_STATE_HOME. + local file = io.open(paths.home .. "/.local/state/omarchy/toggles/hypr/" .. kind .. "-disabled-name", "r") + if not file then + return + end + + local name = file:read("*l") + file:close() + + if name and name ~= "" then + hl.device({ name = name, enabled = false }) + end +end diff --git a/default/hypr/paths.lua b/default/hypr/paths.lua index dd3fc348..c20489f6 100644 --- a/default/hypr/paths.lua +++ b/default/hypr/paths.lua @@ -4,9 +4,19 @@ local home = os.getenv("HOME") +-- A variable that is set but empty means "unset" (XDG Base Directory spec); +-- bash's ${VAR:-fallback} in the sibling tools treats it the same way. +local function env_or(name, fallback) + local value = os.getenv(name) + if value == nil or value == "" then + return fallback + end + return value +end + return { home = home, - config_home = os.getenv("XDG_CONFIG_HOME") or (home .. "/.config"), - state_home = os.getenv("XDG_STATE_HOME") or (home .. "/.local/state"), - omarchy_path = os.getenv("OMARCHY_PATH") or "/usr/share/omarchy", + config_home = env_or("XDG_CONFIG_HOME", home .. "/.config"), + state_home = env_or("XDG_STATE_HOME", home .. "/.local/state"), + omarchy_path = env_or("OMARCHY_PATH", "/usr/share/omarchy"), } diff --git a/default/hypr/require_all.lua b/default/hypr/require_all.lua index 56153a00..b77a55cd 100644 --- a/default/hypr/require_all.lua +++ b/default/hypr/require_all.lua @@ -4,6 +4,8 @@ -- Pass a module prefix for normal package.path modules, e.g. -- require_all.files(paths.omarchy_path .. "/default/hypr/apps", "default.hypr.apps") -- Pass nil as the prefix when the directory itself has been added to package.path. +-- Pass options.exclude as a set of base names (without ".lua") to skip; a legacy +-- file that must never be loaded as code stays on disk for a migration to remove. local M = {} @@ -12,19 +14,23 @@ local function shell_quote(path) end function M.files(dir, module_prefix, options) + local exclude = options and options.exclude or {} local handle = io.popen("find " .. shell_quote(dir) .. " -maxdepth 1 -type f -name '*.lua' -printf '%f\\n' 2>/dev/null | sort") if handle then for filename in handle:lines() do - local module = filename:gsub("%.lua$", "") - if module_prefix then - module = module_prefix .. "." .. module - end + local name = filename:gsub("%.lua$", "") + if not exclude[name] then + local module = name + if module_prefix then + module = module_prefix .. "." .. module + end - if options and options.reload then - package.loaded[module] = nil - end + if options and options.reload then + package.loaded[module] = nil + end - require(module) + require(module) + end end handle:close() end diff --git a/default/hypr/toggles.lua b/default/hypr/toggles.lua index 1ca62205..bb9f28d4 100644 --- a/default/hypr/toggles.lua +++ b/default/hypr/toggles.lua @@ -4,6 +4,20 @@ local require_all = require("default.hypr.require_all") local toggles_dir = paths.state_home .. "/omarchy/toggles/hypr" package.path = toggles_dir .. "/?.lua;" .. package.path -require_all.files(toggles_dir, nil, { reload = true }) +-- touchpad-disabled.lua / touchscreen-disabled.lua were generated Lua in older +-- versions and could carry an injected USB device name. They must never be loaded +-- as code again: exclude them so a not-yet-migrated install cannot execute a +-- leftover payload on reload. The migration recovers the name and deletes them. +require_all.files(toggles_dir, nil, { + reload = true, + exclude = { + ["touchpad-disabled"] = true, + ["touchscreen-disabled"] = true, + }, +}) + +local disabled_input_device = require("default.hypr.disabled-input-device") +disabled_input_device("touchpad") +disabled_input_device("touchscreen") require("default.hypr.workspace-layouts") diff --git a/manual/13-toggles-idle-screensaver.md b/manual/13-toggles-idle-screensaver.md index 6c01398c..2effdac1 100644 --- a/manual/13-toggles-idle-screensaver.md +++ b/manual/13-toggles-idle-screensaver.md @@ -21,7 +21,7 @@ From the terminal, the same switches are `omarchy toggle `. Run `omarchy | Suspend | — | `omarchy toggle suspend` | | Hybrid GPU | — | `omarchy toggle hybrid gpu` | -The touchpad, touchscreen, and hybrid GPU switches live under _Trigger > Hardware_ (`Super + Ctrl + H`) rather than under Toggle, since they only show up when you actually have that hardware. The touchpad and touchscreen ones survive a Hyprland reload — the disabled state is written back out as a small Lua file that Hyprland sources on startup. +The touchpad, touchscreen, and hybrid GPU switches live under _Trigger > Hardware_ (`Super + Ctrl + H`) rather than under Toggle, since they only show up when you actually have that hardware. The touchpad and touchscreen ones survive a Hyprland reload — the disabled device's name is saved to a small state file that Hyprland reads on startup to disable it again. The Toggle menu also carries a few things that aren't `omarchy toggle` commands but behave the same: battery percentage in the bar, workspace layout (`Super + L`), window gaps (`Super + Shift + Backspace`), and the 1-window square aspect (`Super + Ctrl + Backspace`). diff --git a/migrations/1787618700.sh b/migrations/1787618700.sh new file mode 100644 index 00000000..a5e8e6a0 --- /dev/null +++ b/migrations/1787618700.sh @@ -0,0 +1,39 @@ +echo "Store Hyprland input-device names as data instead of generated Lua" + +# omarchy-toggle-input-device used to interpolate hyprctl device names into +# hyprctl eval and a generated Lua file. Those names come from USB descriptors, +# so recover the plain device name as data and delete the generated Lua. A name +# that could have broken out of the old Lua string literal is discarded, not +# trusted. The old script wrote to ~/.local/state regardless of XDG_STATE_HOME. +toggles_dir="$HOME/.local/state/omarchy/toggles/hypr" + +reapply=0 + +for kind in touchpad touchscreen; do + state_file="$toggles_dir/$kind-disabled.lua" + name_file="$toggles_dir/$kind-disabled-name" + + [[ -f $state_file ]] || continue + + if [[ ! -f $name_file && -r $state_file ]]; then + old=$(<"$state_file") + pattern='^hl\.device\(\{ name = "([^"\\[:cntrl:]]+)", enabled = false \}\)$' + if [[ $old =~ $pattern ]]; then + printf '%s\n' "${BASH_REMATCH[1]}" >"$name_file" + fi + fi + + rm -f "$state_file" + + if [[ -f $name_file ]]; then + reapply=1 + fi +done + +# The package hook reloads Hyprland before migrations run, so this session has +# already dropped the disable: the generated Lua is no longer loaded and the +# name file did not exist yet to replace it. Reload once more now that it does, +# or the device the user switched off stays on until their next login. +if (( reapply )); then + hyprctl reload >/dev/null 2>&1 || true +fi diff --git a/test/shell.d/hyprland-paths-test.sh b/test/shell.d/hyprland-paths-test.sh new file mode 100644 index 00000000..4d880754 --- /dev/null +++ b/test/shell.d/hyprland-paths-test.sh @@ -0,0 +1,28 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +require_command lua + +run_paths() { + lua - <<'LUA' +package.path = os.getenv("OMARCHY_PATH") .. "/?.lua;" .. package.path +local paths = require("default.hypr.paths") +assert(paths.config_home == os.getenv("EXPECTED_CONFIG"), "config_home: " .. paths.config_home) +assert(paths.state_home == os.getenv("EXPECTED_STATE"), "state_home: " .. paths.state_home) +LUA +} + +HOME="/home/test-user" OMARCHY_PATH="$ROOT" \ + XDG_CONFIG_HOME= XDG_STATE_HOME= \ + EXPECTED_CONFIG="/home/test-user/.config" EXPECTED_STATE="/home/test-user/.local/state" \ + run_paths +pass "empty XDG path variables fall back to their defaults" + +HOME="/home/test-user" OMARCHY_PATH="$ROOT" \ + XDG_CONFIG_HOME="/custom/config" XDG_STATE_HOME="/custom/state" \ + EXPECTED_CONFIG="/custom/config" EXPECTED_STATE="/custom/state" \ + run_paths +pass "set XDG path variables are honored" diff --git a/test/shell.d/monitor-output-name-test.sh b/test/shell.d/monitor-output-name-test.sh new file mode 100644 index 00000000..14cd8852 --- /dev/null +++ b/test/shell.d/monitor-output-name-test.sh @@ -0,0 +1,123 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +require_command jq + +tmpdir=$(mktemp -d) +trap 'rm -rf "$tmpdir"' EXIT + +stub_dir="$tmpdir/bin" +home_dir="$tmpdir/home" +monitors_json="$tmpdir/monitors.json" +flag_dir="$home_dir/.local/state/omarchy/toggles/hypr" +mkdir -p "$stub_dir" "$flag_dir" + +make_stub() { + local name=$1 + local body=$2 + printf '#!/bin/bash\n%s\n' "$body" >"$stub_dir/$name" + chmod +x "$stub_dir/$name" +} + +make_stub omarchy-notification-send ':' +make_stub omarchy-hyprland-monitor-external-active 'exit 0' +make_stub omarchy-hyprland-toggle-disabled 'exit 0' +make_stub omarchy-hyprland-toggle ':' +make_stub omarchy-hyprland-monitor-internal ':' +make_stub omarchy-hyprland-monitor-internal-mirror ':' +make_stub omarchy-hw-clamshell 'exit 0' +make_stub omarchy-hyprland-monitor-laptop 'printf "%s\n" "$LAPTOP_NAME"' +make_stub hyprctl 'case "$1" in + monitors) cat "$MONITORS_JSON" ;; + eval) printf "%s\n" "$2" >>"$EVAL_LOG" ;; +esac' + +eval_log="$tmpdir/eval.log" + +run_monitor() { + local command=$1 + shift + : >"$eval_log" + HOME="$home_dir" \ + XDG_STATE_HOME="$home_dir/.local/state" \ + LAPTOP_NAME="${LAPTOP_NAME:-eDP-1}" \ + MONITORS_JSON="$monitors_json" \ + EVAL_LOG="$eval_log" \ + PATH="$stub_dir:$ROOT/bin:$PATH" \ + "$ROOT/bin/$command" "$@" +} + +printf '[{"name":"eDP-1"},{"name":"DP-3"}]\n' >"$monitors_json" + +disable_flag="$flag_dir/internal-monitor-disable.lua" +run_monitor omarchy-hyprland-monitor-internal off +grep -Fx 'hl.monitor({ output = "eDP-1", disabled = true })' "$disable_flag" >/dev/null || + fail "internal off writes the connector name into the toggle flag" +pass "internal off accepts a plain connector name" + +rm -f "$disable_flag" +set +e +LAPTOP_NAME='eDP-1", disabled = false })os.execute("calc")--' \ + run_monitor omarchy-hyprland-monitor-internal off >/dev/null 2>&1 +status=$? +set -e +(( status != 0 )) || fail "internal off rejects a monitor name with Lua metacharacters" +[[ ! -e $disable_flag ]] || fail "an unsafe monitor name is not written as Lua" +pass "internal off refuses an unsafe monitor name" + +mirror_flag="$flag_dir/internal-monitor-mirror.lua" +run_monitor omarchy-hyprland-monitor-internal-mirror on +grep -Fx 'hl.monitor({ output = "DP-3", mode = "preferred", position = "auto", scale = 1, mirror = "eDP-1" })' \ + "$mirror_flag" >/dev/null || + fail "mirror on writes the connector names into the toggle flag" +pass "mirror on accepts plain connector names" + +rm -f "$mirror_flag" +printf '[{"name":"eDP-1"},{"name":"HEAD\\" })os.execute(\\"calc\\")--"}]\n' >"$monitors_json" +set +e +run_monitor omarchy-hyprland-monitor-internal-mirror on >/dev/null 2>&1 +status=$? +set -e +(( status != 0 )) || fail "mirror on rejects an external name with Lua metacharacters" +[[ ! -e $mirror_flag ]] || fail "an unsafe external monitor name is not written as Lua" +pass "mirror on refuses an unsafe headless output name" + +# The clamshell sync writes the internal-monitor name into generated Lua too. +clamshell_flag="$flag_dir/internal-monitor-clamshell.lua" +printf '[{"name":"eDP-1"}]\n' >"$monitors_json" +rm -f "$clamshell_flag" +run_monitor omarchy-hyprland-monitor-clamshell +grep -Fx 'hl.monitor({ output = "eDP-1", disabled = true })' "$clamshell_flag" >/dev/null || + fail "clamshell disable writes the connector name into the toggle flag" +pass "clamshell disable accepts a plain connector name" + +rm -f "$clamshell_flag" +set +e +LAPTOP_NAME='eDP-1", disabled = true })os.execute("calc")--' \ + run_monitor omarchy-hyprland-monitor-clamshell >/dev/null 2>&1 +status=$? +set -e +(( status != 0 )) || fail "clamshell rejects a monitor name with Lua metacharacters" +[[ ! -e $clamshell_flag ]] || fail "an unsafe internal monitor name is not written as clamshell Lua" +pass "clamshell refuses an unsafe internal monitor name" + +# The scaling command eval's the focused-monitor name into a Lua string. +printf '[{"name":"eDP-1","focused":true,"scale":1.0,"width":1920,"height":1080,"refreshRate":60.0}]\n' \ + >"$monitors_json" +run_monitor omarchy-hyprland-monitor-scaling 1.6 +grep -F 'hl.monitor({ output = "eDP-1"' "$eval_log" >/dev/null || + fail "scaling eval's the focused connector name" +pass "scaling accepts a plain connector name" + +printf '[{"name":"eDP-1\\" })os.execute(\\"calc\\")--","focused":true,"scale":1.0,"width":1920,"height":1080,"refreshRate":60.0}]\n' \ + >"$monitors_json" +set +e +run_monitor omarchy-hyprland-monitor-scaling 1.6 >/dev/null 2>&1 +status=$? +set -e +(( status != 0 )) || fail "scaling rejects a focused monitor name with Lua metacharacters" +[[ ! -s $eval_log ]] || fail "an unsafe focused monitor name is not eval'd as Lua" +pass "scaling refuses an unsafe focused monitor name" diff --git a/test/shell.d/toggle-input-device-test.sh b/test/shell.d/toggle-input-device-test.sh new file mode 100755 index 00000000..2b0c10d8 --- /dev/null +++ b/test/shell.d/toggle-input-device-test.sh @@ -0,0 +1,286 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +require_command lua + +tmpdir=$(mktemp -d) +trap 'rm -rf "$tmpdir"' EXIT + +stub_dir="$tmpdir/bin" +home_dir="$tmpdir/home" +xdg_decoy="$tmpdir/xdg-decoy" +log_file="$tmpdir/hyprctl.log" +marker="$tmpdir/marker" +mkdir -p "$stub_dir" "$home_dir" "$xdg_decoy" + +state_dir="$home_dir/.local/state/omarchy/toggles/hypr" +name_file="$state_dir/touchpad-disabled-name" +state_lua="$state_dir/touchpad-disabled.lua" + +cat >"$stub_dir/hyprctl" <<'EOF' +#!/bin/bash +case $1 in + eval) printf '%s\n' "$2" >>"$HYPRCTL_LOG" ;; + reload) printf 'reload\n' >>"$HYPRCTL_LOG" ;; +esac +EOF +chmod +x "$stub_dir/hyprctl" + +cat >"$stub_dir/omarchy-osd" <<'EOF' +#!/bin/bash +: +EOF +chmod +x "$stub_dir/omarchy-osd" + +stub_device() { + local kind=$1 + local name=$2 + cat >"$stub_dir/omarchy-hw-$kind" </dev/null) ]] || + fail "input-device state must ignore XDG_STATE_HOME" +} + +: >"$log_file" +stub_device touchpad 'elan-touchpad' + +run_toggle touchpad off +[[ $(<"$name_file") == "elan-touchpad" ]] || fail "touchpad disable stores the device name as data" +[[ ! -e $state_lua ]] || fail "touchpad disable writes no generated Lua" +grep -Fx 'hl.device({ name = "elan-touchpad", enabled = false })' "$log_file" >/dev/null || + fail "touchpad disable applies a quoted Lua device name" +assert_decoy_untouched +pass "touchpad disable persists the device name as data" + +: >"$log_file" +run_toggle touchpad on +[[ ! -e $name_file ]] || fail "touchpad enable clears the persisted device name" +grep -Fx 'hl.device({ name = "elan-touchpad", enabled = true })' "$log_file" >/dev/null || + fail "touchpad enable applies a quoted Lua device name" +pass "touchpad enable clears persisted disable state" + +run_toggle touchpad +[[ -f $name_file ]] || fail "default toggle action disables an enabled touchpad" +run_toggle touchpad +[[ ! -e $name_file ]] || fail "default toggle action enables a disabled touchpad" +pass "default toggle action flips the persisted state" + +: >"$log_file" +stub_device touchscreen 'wacom-hid-52eb-finger' +ts_name_file="$state_dir/touchscreen-disabled-name" + +run_toggle touchscreen off +[[ $(<"$ts_name_file") == "wacom-hid-52eb-finger" ]] || + fail "touchscreen disable stores the device name as data" +grep -Fx 'hl.device({ name = "wacom-hid-52eb-finger", enabled = false })' "$log_file" >/dev/null || + fail "touchscreen disable applies a quoted Lua device name" +run_toggle touchscreen on +[[ ! -e $ts_name_file ]] || fail "touchscreen enable clears the persisted device name" +pass "touchscreen routes through the same persisted-name state" + +: >"$log_file" +rm -f "$marker" +stub_device touchpad 'touchpad"; touch '"$marker"'; echo "' + +run_toggle touchpad off +[[ ! -e $marker ]] || fail "touchpad disable does not execute metacharacters in the device name" +[[ $(<"$name_file") == 'touchpad"; touch '"$marker"'; echo "' ]] || + fail "a hostile device name is stored only as data" +[[ ! -e $state_lua ]] || fail "a hostile device name is not written as Lua" +grep -F 'hl.device({ name = "touchpad\"' "$log_file" >/dev/null || + fail "hyprctl eval Lua-quotes quotes in the device name" "$(<"$log_file")" +pass "touchpad disable treats USB device names as data" + +HOME="$home_dir" XDG_STATE_HOME="$xdg_decoy" OMARCHY_PATH="$ROOT" MARKER="$marker" lua - <<'LUA' +local seen = {} +hl = { + device = function(opts) + table.insert(seen, opts) + end, +} + +dofile(os.getenv("OMARCHY_PATH") .. "/default/hypr/bootstrap.lua") +require("default.hypr.toggles") +assert(#seen == 1, "reload disables one device") +assert(seen[1].enabled == false) +assert(seen[1].name == 'touchpad"; touch ' .. os.getenv("MARKER") .. '; echo "', "device name is passed as a string") +LUA +pass "Hyprland reload loads the device name as a string" + +# Public PoC device name: USB iProduct is interpolated into hl.device({ name = "..." }). +# os.execute is stubbed so the string is only checked as data. +poc_name='trackpad"})os.execute("~/calc&")--' +stub_device touchpad "$poc_name" + +run_toggle touchpad on +: >"$log_file" +run_toggle touchpad off +[[ $(<"$name_file") == "$poc_name" ]] || fail "PoC device name is stored only as data" +[[ ! -e $state_lua ]] || fail "PoC device name is not written as Lua" + +HOME="$home_dir" XDG_STATE_HOME="$xdg_decoy" OMARCHY_PATH="$ROOT" \ + POC_NAME="$poc_name" EVAL_SNIPPET="$(<"$log_file")" lua - <<'LUA' +local poc = os.getenv("POC_NAME") +local snippet = os.getenv("EVAL_SNIPPET") +local seen, executed = {}, false + +hl = { + device = function(opts) + table.insert(seen, opts) + end, +} +os.execute = function() + executed = true +end + +assert(load(snippet, "eval", "t"))() +assert(executed == false, "quoted hyprctl eval must not run os.execute") +assert(#seen == 1) +assert(seen[1].name == poc) +assert(seen[1].enabled == false) + +seen, executed = {}, false +assert(load('hl.device({ name = "' .. poc .. '", enabled = false })', "unquoted", "t"))() +assert(executed == true, "unquoted interpolation is the Lua injection") + +seen, executed = {}, false +dofile(os.getenv("OMARCHY_PATH") .. "/default/hypr/bootstrap.lua") +require("default.hypr.toggles") +assert(executed == false, "reload must not run os.execute") +assert(#seen == 1) +assert(seen[1].name == poc) +LUA +pass "PoC device name cannot execute via eval or reload" + +cat >"$stub_dir/omarchy-hw-touchpad" <<'EOF' +#!/bin/bash +printf 'evil\nname\n' +EOF +chmod +x "$stub_dir/omarchy-hw-touchpad" + +rm -f "$name_file" +set +e +run_toggle touchpad off >/dev/null 2>&1 +status=$? +set -e +(( status != 0 )) || fail "disable rejects a device name with a newline" +[[ ! -e $name_file ]] || fail "a rejected device name is not persisted" +pass "disable rejects control characters in a device name" + +printf 'elan-touchpad\n' >"$name_file" +set +e +run_toggle touchpad on >/dev/null 2>&1 +status=$? +set -e +(( status != 0 )) || fail "enable still reports an invalid device name" +[[ ! -e $name_file ]] || fail "enable clears persisted state even with an invalid device name" +pass "a bad device name cannot wedge the persisted disable" + +cat >"$stub_dir/omarchy-hw-touchpad" <<'EOF' +#!/bin/bash +: +EOF +chmod +x "$stub_dir/omarchy-hw-touchpad" + +set +e +run_toggle touchpad off >/dev/null 2>&1 +status=$? +set -e +(( status != 0 )) || fail "disable errors when no device is found" +[[ ! -e $name_file ]] || fail "no state is written when no device is found" +pass "disable errors when no device is found" + +# The migration runs with the same XDG decoy: legacy files were written to +# ~/.local/state, so that is where it must look no matter what XDG says. +run_migration() { + HOME="$home_dir" XDG_STATE_HOME="$xdg_decoy" HYPRCTL_LOG="$log_file" \ + PATH="$stub_dir:$ROOT/bin:$PATH" \ + bash -euo pipefail "$ROOT/migrations/1787618700.sh" >/dev/null +} + +mkdir -p "$state_dir" +rm -f "$state_dir"/*-disabled-name +printf 'hl.device({ name = "synps/2-synaptics-touchpad", enabled = false })\n' >"$state_lua" +printf 'hl.device({ name = "hostile\\"")", enabled = false })\n' >"$state_dir/touchscreen-disabled.lua" + +: >"$log_file" +run_migration +[[ $(<"$name_file") == "synps/2-synaptics-touchpad" ]] || + fail "migration recovers a device name containing a slash" +[[ ! -e $state_lua ]] || fail "migration deletes the generated touchpad Lua" +[[ ! -e $state_dir/touchscreen-disabled-name ]] || + fail "migration does not copy a hostile name out of generated Lua" +[[ ! -e $state_dir/touchscreen-disabled.lua ]] || + fail "migration deletes hostile generated Lua even when no name is recovered" +assert_decoy_untouched +# The package hook reloads Hyprland before migrations run, so the disable was +# already dropped for this session; the migration has to put it back. +grep -Fx 'reload' "$log_file" >/dev/null || + fail "migration reloads so the recovered disable applies to this session" +pass "migration recovers plain names and discards hostile generated Lua" + +printf 'kept-name\n' >"$name_file" +printf 'hl.device({ name = "other-touchpad", enabled = false })\n' >"$state_lua" +run_migration +[[ $(<"$name_file") == "kept-name" ]] || fail "migration keeps an existing device-name file" +[[ ! -e $state_lua ]] || fail "migration still deletes the generated Lua" +pass "migration is idempotent over an existing device-name file" + +rm -f "$name_file" +printf 'garbage\n' >"$state_lua" +chmod 000 "$state_lua" +run_migration +[[ ! -e $state_lua ]] || fail "migration removes an unreadable generated Lua" +[[ ! -e $name_file ]] || fail "no name is recovered from an unreadable file" +pass "an unreadable state file does not wedge the migration" + +: >"$log_file" +run_migration +[[ ! -s $log_file ]] || fail "migration with nothing to migrate does not reload" +pass "migration no-ops with nothing left to migrate" + +# A compromised install carries a leftover generated touchpad-disabled.lua whose +# device name broke out into os.execute. Until the migration deletes it, a reload +# must not source it. toggles.lua excludes those two names from require_all, so the +# payload never runs, while a current name-file disable still applies. +reload_home="$tmpdir/reload-home" +reload_state="$reload_home/.local/state/omarchy/toggles/hypr" +mkdir -p "$reload_state" +reload_marker="$tmpdir/reload-executed" +rm -f "$reload_marker" +printf 'hl.device({ name = "trackpad"})os.execute("touch %s")--", enabled = false })\n' "$reload_marker" \ + >"$reload_state/touchpad-disabled.lua" +printf 'elan-touchpad\n' >"$reload_state/touchpad-disabled-name" + +HOME="$reload_home" XDG_STATE_HOME="$reload_home/.local/state" OMARCHY_PATH="$ROOT" lua - <<'LUA' +local disabled = {} +hl = { device = function(opts) table.insert(disabled, opts) end } +dofile(os.getenv("OMARCHY_PATH") .. "/default/hypr/bootstrap.lua") +require("default.hypr.toggles") +assert(#disabled == 1, "only the current name-file disable is applied") +assert(disabled[1].name == "elan-touchpad", "disable uses the stored device name") +assert(disabled[1].enabled == false) +LUA +[[ ! -e $reload_marker ]] || fail "a leftover legacy generated toggle Lua must not execute on reload" +pass "reload excludes leftover legacy toggle Lua while applying the data disable"