* 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 <noreply@anthropic.com> 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> Co-Authored-By: Codex XHigh <codex@openai.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Omarchybot <omabot@omarchy.org> Co-authored-by: Codex XHigh <codex@openai.com>
287 lines
10 KiB
Bash
Executable File
287 lines
10 KiB
Bash
Executable File
#!/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" <<EOF
|
|
#!/bin/bash
|
|
printf '%s\n' '$name'
|
|
EOF
|
|
chmod +x "$stub_dir/omarchy-hw-$kind"
|
|
}
|
|
|
|
# XDG_STATE_HOME deliberately points away from HOME everywhere below: the
|
|
# input-device state is hardcoded to ~/.local/state like the sibling toggle
|
|
# tools and the pre-migration script, so nothing may read or write the XDG
|
|
# directory.
|
|
run_toggle() {
|
|
HOME="$home_dir" \
|
|
XDG_STATE_HOME="$xdg_decoy" \
|
|
HYPRCTL_LOG="$log_file" \
|
|
PATH="$stub_dir:$ROOT/bin:$PATH" \
|
|
"$ROOT/bin/omarchy-toggle-input-device" "$@"
|
|
}
|
|
|
|
assert_decoy_untouched() {
|
|
[[ -z $(find "$xdg_decoy" -mindepth 1 -print -quit 2>/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"
|