fix(capture): restore key overlays after hardening

Validate the systemd runtime path using the process effective UID instead of shell ownership probes that fail in Hyprland's embedded Lua runtime. Keep Quickshell FileView watchers live by updating their fixed, private state paths in place and seed both state files when the hook loads.
This commit is contained in:
Bob Myrick
2026-08-25 21:20:05 -04:00
parent ee988956bb
commit 7b3fa2f0b3
+33 -34
View File
@@ -14,18 +14,29 @@
local function shell_quote(s) return "'" .. s:gsub("'", "'\\''") .. "'" end local function shell_quote(s) return "'" .. s:gsub("'", "'\\''") .. "'" end
local function effective_uid()
local f = io.open("/proc/self/status", "r")
if not f then return nil end
for line in f:lines() do
local uid = line:match("^Uid:%s+%d+%s+(%d+)")
if uid then
f:close()
return uid
end
end
f:close()
return nil
end
local function is_runtime_secure(r) local function is_runtime_secure(r)
if not r or r == "" then return false end if not r or r == "" then return false end
if r == "/tmp" then return false end if r == "/tmp" then return false end
if r:sub(1, 1) ~= "/" then return false end if r:sub(1, 1) ~= "/" then return false end
local q = shell_quote(r) -- Shell ownership probes fail from Hyprland's embedded Lua environment.
-- Must be a directory owned by the current user and, if stat is -- /run/user is root-controlled, so accepting only this process's systemd
-- available, mode 0700 (systemd's XDG_RUNTIME_DIR default). The -- runtime path still excludes shared or caller-supplied directories.
-- stat check is skipped when stat is missing so we don't fail-closed local uid = effective_uid()
-- on minimal containers. return uid ~= nil and r == "/run/user/" .. uid
local cmd = "test -d " .. q .. " && test -O " .. q .. " && { p=$(stat -c %a " .. q .. " 2>/dev/null); [ -z \"$p\" ] || [ \"$p\" = \"700\" ]; }"
local res = os.execute(cmd)
return res == 0 or res == true
end end
local runtime = os.getenv("XDG_RUNTIME_DIR") local runtime = os.getenv("XDG_RUNTIME_DIR")
@@ -38,36 +49,19 @@ if not is_runtime_secure(runtime) then
runtime = nil runtime = nil
end end
local STATE_FILE = runtime and (runtime .. "/omarchy-key-visualizer.json") or nil local STATE_FILE = runtime and (runtime .. "/omarchy-key-visualizer.json") or nil
local SUPER_FLAG = runtime and (runtime .. "/omarchy-key-visualizer-super") or nil
local function secure_write(path, content) local function secure_write(path, content)
if not path then return false end if not runtime or (path ~= STATE_FILE and path ~= SUPER_FLAG) then return false end
-- Reject the exact predictable fallback that the security review flagged. -- FileView watches the existing inode, so replacing the path on every key
-- A valid XDG_RUNTIME_DIR under /tmp with a random suffix and 0700 is -- leaves Quickshell attached to an unlinked file. Updating in place keeps
-- allowed because it passed is_runtime_secure(). -- the watcher live. The containing runtime directory is private to the
if path == "/tmp/omarchy-key-visualizer.json" or path == "/tmp/omarchy-key-visualizer-super" then return false end -- effective user and the two accepted paths are fixed above.
local q = shell_quote(path) local f = io.open(path, "w")
-- Refuse to follow a symlink at the destination (O_NOFOLLOW mitigation).
-- `test ! -L` succeeds when the file does not exist or is not a symlink;
-- it fails only when the destination is a symlink, which we must not follow.
local not_symlink = os.execute("test ! -L " .. q)
if not (not_symlink == 0 or not_symlink == true) then
print("[key-visualizer] refusing to write symlink: " .. path)
return false
end
local tmp = path .. ".tmp"
local f = io.open(tmp, "w")
if not f then return false end if not f then return false end
f:write(content) f:write(content)
f:close() f:close()
os.execute("chmod 600 " .. shell_quote(tmp) .. " 2>/dev/null") os.execute("chmod 600 " .. shell_quote(path) .. " 2>/dev/null")
-- Atomic replace; avoids truncating a file that may have been swapped
-- between the symlink check and the open (TOCTOU mitigation).
local ok = os.rename(tmp, path)
if not ok then
os.execute("rm -f " .. shell_quote(tmp) .. " 2>/dev/null")
return false
end
os.execute("chmod 600 " .. q .. " 2>/dev/null")
return true return true
end end
@@ -200,7 +194,6 @@ end
-- Super-held flag: the panel/display watches this to know when to capture the -- Super-held flag: the panel/display watches this to know when to capture the
-- SUPER+drag on the overlay (instead of a window underneath). Written only on -- SUPER+drag on the overlay (instead of a window underneath). Written only on
-- transitions so it does not spam the filesystem on every key. -- transitions so it does not spam the filesystem on every key.
local SUPER_FLAG = runtime and (runtime .. "/omarchy-key-visualizer-super") or nil
local last_super = nil local last_super = nil
local function super_down() local function super_down()
return pressed[133] or pressed[134] return pressed[133] or pressed[134]
@@ -213,6 +206,12 @@ local function emit_super()
secure_write(SUPER_FLAG, down and "1" or "0") secure_write(SUPER_FLAG, down and "1" or "0")
end end
-- Create both files when the hook loads. This clears stale state after a
-- compositor restart and gives FileView stable paths to watch before the
-- first keyboard event arrives.
emit()
emit_super()
-- Combos: a combination of keys is treated as a unit. The display only -- Combos: a combination of keys is treated as a unit. The display only
-- updates on key-down (the combo grows as you press) and when the last key -- updates on key-down (the combo grows as you press) and when the last key
-- is released (the empty payload starts the panel's linger with the last -- is released (the empty payload starts the panel's linger with the last