[Security] Stop USB device names from being executed as Hyprland Lua (backport of #8129)
Backport of the input-device name fix (PR #8129 by @acrogenesis, merged to
quattro as 9285b19d) onto the v4-0-1 release branch.
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. XF86TouchpadToggle is bound with
locked = true, so a malicious USB name reached Lua execution from the lock
screen as the logged-in user, and a persisted disable made it run on every
start. Publicly reported by Jorrit Jongma / Chainfire.
The disable is no longer executable Lua anywhere. The device name is stored as
plain-text data in a *-disabled-name sidecar and read back by a packaged module,
default/hypr/disabled-input-device.lua, on every reload; the live hyprctl eval
Lua-quotes the name and rejects control characters outright. The reload loader
excludes the two legacy filenames, so a leftover generated *-disabled.lua on a
not-yet-migrated install can never be sourced as code again, and a migration
recovers the device name from it and deletes it, sanitizing installs that ran
the vulnerable version. All four monitor scripts validate an output name against
a plain-connector-name pattern before writing it as Lua, closing the same latent
pattern in the siblings, and paths.lua treats a set-but-empty XDG_STATE_HOME as
unset to match the bash side.
Clean cherry-pick: all fourteen files are byte-identical to quattro, so merging
v4-0-1 into quattro resolves without a conflict. This branch ships no leftover
*-disabled.lua template of its own -- the tracked "disabled" files are the same
two quattro has -- so the migration is the only path that has to sanitize
anything here.
test/shell passes: 192 files, including the three this adds. The toggle suite's
public-PoC case passes here, as do the monitor scripts' accept/reject cases and
the XDG path cases. test/cli passes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018DEMYa9UWtroz93DhMTtcV
This commit is contained in:
1 parent
c6d676f23c
commit
2e989e35e5
14 files changed
+612
-27
No files matched your search
@@ -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]+)?$ ]]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -7,44 +7,70 @@
|
||||
KIND="${1:-}"
|
||||
ACTION="${2:-toggle}"
|
||||
|
||||
usage() {
|
||||
echo "Usage: omarchy-toggle-input-device <touchpad|touchscreen> [on|off|toggle]" >&2
|
||||
}
|
||||
|
||||
case "$KIND" in
|
||||
touchpad) LABEL="Touchpad" ICON="touchpad" ;;
|
||||
touchscreen) LABEL="Touchscreen" ICON="touch" ;;
|
||||
*)
|
||||
echo "Usage: omarchy-toggle-input-device <touchpad|touchscreen> [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 <touchpad|touchscreen> [on|off|toggle]" >&2
|
||||
usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -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
|
||||
+13
-3
@@ -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"),
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
@@ -21,7 +21,7 @@ From the terminal, the same switches are `omarchy toggle <thing>`. 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`).
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
Executable
+286
@@ -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" <<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"
|
||||
Reference in new issue
Block a user