Commit Graph
23 Commits
Author SHA1 Message Date
acrogenesisandRyan Hughes ca4f596a14 Harden existing key-based SSH setups 2026-08-30 18:07:59 -04:00
acrogenesis f8d7fae7a8 Match website security guidance 2026-08-30 13:26:18 -06:00
acrogenesis 3def390764 Add security policy 2026-08-30 13:19:11 -06:00
acrogenesis 8e41961c7d Close privileged cleanup review gaps 2026-08-29 23:55:42 -06:00
acrogenesis f91d2e5453 Restore strict migration ordering 2026-08-29 22:42:33 -06:00
acrogenesis 4d697a063c Harden privileged cleanup review fixes 2026-08-29 21:08:33 -06:00
acrogenesis 4996941513 Address privileged cleanup review findings 2026-08-29 20:03:46 -06:00
acrogenesis 7ed761fb31 Stop the heredoc check writing its own exemption
The failure advice printed a ready-to-paste annotation with the scan's
verdict already filled in, so the shortest way past the check was to copy
back what it had just concluded. That is worst exactly where the scan is
weakest: a path it cannot follow through a variable reads as an ordinary
value, and the annotation it offers for that case is paths=none.

Print the annotation with the path list left blank and say why the author
has to fill it in. The scan's own reading stays in the report above it, so
nothing diagnostic is lost.
2026-08-29 19:23:02 -06:00
acrogenesis 80e7c25b37 Fail the sudoers cleanup when it cannot elevate to look
Running the migration on a real machine with no cached sudo credentials
printed sudo's "a terminal is required to read the password" and still
exited 0. bin/omarchy-migrate writes the completion marker on a zero exit,
so the cleanup would have been recorded as done on every install that runs
migrations without a terminal, and never tried again.

Probe for elevation before the combined existence check and exit non-zero
when it fails, so the marker stays unwritten and the next run retries. The
probe is skipped when the directory is readable as-is, which is the case
when migrations run as root.
2026-08-29 19:23:02 -06:00
acrogenesis 394c1371c9 Model what each parser does with an empty and a dangling directive
Review of the previous commits turned up four places where the predicates
and their tests disagreed with the tools they are modelling, each checked
against udevadm verify, systemd-analyze verify and visudo -cf rather than
against reading of the sources.

An empty ExecStop= resets the list, so a unit an administrator neutralised
that way runs nothing at shutdown and is no longer ours to remove; the
predicate now tracks the last state instead of returning on the first home
path it sees. A file whose last line ends in a backslash still carries a
live directive for systemd, so the pending logical line is emitted at EOF;
udev ignores such a line and sudo rejects the file outright, so this costs
those two nothing. The scanner's taint pass now reads += appends, which its
own comment already promised: the value of an append is no use, but a name
that reaches a user root through one has to be judged on it.

Two regression guards passed against the implementations they were written
for. The udev continuation fixture put the whole RUN+= below the comment, so
it matched whether or not the pending half was carried across; the split now
falls inside the RUN+= value. The sudoers one kept its file on the strength
of a spec above the comment, so it could not fail either; the hand-written
spec now sits below. Both fail against a mutant that discards the pending
line. The comment above the second also claimed a continued comment stays a
comment, which visudo contradicts.
2026-08-29 19:23:02 -06:00
acrogenesis d593847728 Fail the suite on privileged writes through unquoted heredocs
An installer that writes a root-owned file through a heredoc with an
unquoted delimiter (<<EOF rather than <<'EOF') has the installing user's
shell expand the body first, so a user-controlled value is baked in as a
literal. Send that into /etc and root later reads or executes a path the
unprivileged user picked: a udev rule carrying
$HOME/.local/share/omarchy/bin/... resolves through a symlink that user
owns, so replacing the symlink gets their code run as root.

Add the static check. A heredoc is flagged when its delimiter is
unquoted, its body contains an install-time expansion (escaped \$VAR does
not count, since that is left for a root daemon to expand at runtime),
and its output reaches /etc, /usr, /opt, /srv, /boot or /var/lib via sudo
tee, sudo dd, a redirect, or an install/cp/mv of the generated scratch
file. Destinations written as variables are resolved from the file's own
assignments.

Sites that genuinely need install-time expansion declare it inline:

  # omarchy:heredoc-expands paths=none -- $servers is a validated IP list

paths= is machine-checked against the expansions the scanner finds to be
path-shaped, so this cannot become a rubber stamp: adding a $HOME/... to
an already-annotated heredoc makes the declaration false and trips the
check again. Path expansions anchored under a root-owned prefix, as in
"/etc/systemd/system/$unit", are correctly not path-shaped.

Annotate the sites the scan reports, each of which expands a scalar: DNS
addresses in omarchy-dns, a literal PAM line in
omarchy-setup-security-fingerprint, kernel cmdline parameters and
usernames in omarchy-upgrade-to-quattro. omarchy-provision-owner expanded
a unit name that was already a constant, so its delimiter is now quoted
and the name hardcoded; the generated unit file is byte-identical.
omarchy-windows-vm declares paths=storage,shared, the only site that
interpolates a user-chosen path.

Fixtures prove non-vacuity in both directions: the write routes other
than a pipe into sudo tee, the shapes that must stay quiet, udev rules
and a shutdown unit taken verbatim from this repository's history, and
the rubber-stamp case where a paths=none annotation on a baked $HOME path
still fails.
2026-08-29 19:23:02 -06:00
acrogenesis 96ed473ce1 Remove privileged files left behind by retired Omarchy installers
Three installers that no longer exist each left a root-owned file on
disk, and nothing in Omarchy has ever removed any of them.

/etc/sudoers.d/first-run granted the installing account passwordless
sudo for the rest of the first boot, unrestricted /usr/bin/systemctl
included from 2025-10-14 on. omarchy-first-run clears its first-run.mode
guard before eight set -e steps and only deletes the grant after them,
so any failure in between strands it with nothing left to retry.

/etc/sudoers.d/tsui named whatever $(which tsui) resolved to for the
installing user, normally a binary under their own home that the vendor
script had just written without sudo.

/etc/systemd/system/omarchy-plymouth-shutdown.service ran an ExecStop
under the installing user's home as uid 0 on every shutdown.

Each file is judged against what the installer that wrote it actually
produced. The first-run grant was rewritten eight times and only the
last four carry both Cmnd_Alias lines, so rather than key on those, every
active line must be one the installer emitted and one of them must be
its own self-cleanup. The shutdown unit is disabled but never stopped:
stopping it is what would run the ExecStop being taken away.

Generalize the migrations.md exception, which framed itself around pre-4
layout transitions and so did not cover installers retired on their own.
2026-08-29 19:22:44 -06:00
acrogenesis cd519283fe Remove Omarchy 3 power udev rules that run a command out of a user home
Omarchy 3 wrote 99-power-profile.rules and 99-wifi-powersave.rules with
an unquoted heredoc, baking the installing user's home into a rule udev
runs as root. That path resolves through ~/.local/share/omarchy, a
symlink the unprivileged user owns, so replacing it and provoking a
power_supply event runs their code as root. HEAD points the rules at
/usr/bin under new names, but the one-shot cleanup for the old
filenames was dropped, leaving the file on every install that came up
through the 3.x line.

Remove a legacy file only when an active RUN+= really does run that
filename's binary out of a home directory, so a rule of the same name a
user wrote themselves stays, comments and all.
2026-08-29 19:22:44 -06:00
acrogenesis a75551de4e Allow a migration to clear a pre-4 layout that is a security defect
The upgrade command only runs on a machine still crossing 3 to 4, so a
vulnerable file an old installer wrote never gets swept on an install
that crossed already. It ends by running omarchy-migrate, so a single
migration reaches both populations.
2026-08-29 19:22:44 -06:00
acrogenesis bafc9a1000 Write browser theme colour through a passwordless helper
Managed policy dirs are enterprise trust roots, so they stay 0755 root:root. The menu path takes root for that one write through a sudoers glob of six hex digits, the same shape as omarchy-dns, and falls back to pkexec where the grant is not installed. Drop omarchy-browser-policy; a group member could plant any JSON, not just a colour.
2026-08-25 13:01:01 -06:00
acrogenesis 44a186afe4 Replace planted policy directory symlinks instead of following them
install -d follows a managed or distribution symlink and would chmod the target. Unlink those paths first, and treat a dangling symlink as a directory the migration still has to repair.
2026-08-25 12:14:52 -06:00
acrogenesis bebe19bc70 Harden browser policy parent directories and validate theme RGB
install -d follows a planted ancestor symlink, and a writable parent can rename the managed leaf aside. chromium.theme is user-installed, so only a 0-255 RGB triple becomes a colour.
2026-08-25 12:10:08 -06:00
acrogenesis 87dfa14c56 Keep a trusted Firefox policies.json when repairing the directory
A world-writable distribution dir failed the hardened check even when
policies.json was already root-owned, and setup then overwrote it.
2026-08-25 12:04:03 -06:00
acrogenesis b0e6611c70 Require root-owned Firefox policies before skipping repair
The hardened gate only looked at the distribution directory. A regular
policies.json planted under the old 777 mode would then be left in place
if the directory later looked 755/root.
2026-08-25 12:04:02 -06:00
acrogenesis 95b791af16 Stop world-writable browser policy directories
Chromium managed policy is mandatory for every profile. World-writable
dirs let any local uid plant policy, including force-installed
extensions. Write goes through the omarchy-browser-policy group at 2775
so theme colour still works without other-write.
2026-08-25 12:04:02 -06:00
9285b19d6a [Security] Stop USB device names from being executed as Hyprland Lua (#8129)
* 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>
2026-08-25 11:03:12 +02:00
b71c60fe30 [Security] Stop a video title from becoming the Download Video play command (#7847)
* Stop a video title from becoming the Download Video play command

The host parsed yt-dlp's after_move line as title plus path, so a newline in page metadata could forge the path. Clicking the toast then handed that value to mpv as options. Print only the real file, ignore anything that is not inside the download dir, and invoke mpv with --.

* Refuse downloads whose video title contains control characters

The hoodie page still offered a real hidden clip, so yt-dlp saved it even after the play-action fix. A title with newlines is not a legitimate name; abort before the download and tell the user it was refused.

* Test the forged record in the order yt-dlp emits it

The records ran forged-first and good-last, so the assertion measured recovery after
bad records rather than preservation of an already-captured path when a forged record
arrives afterwards. That is the shape a hostile title actually produces, because a
title ending in a newline closes its own record and leaves the genuine path on a line
the loop ignores. As written the assertion passed with resolve_download_file replaced
by a no-op.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Resolve the download path without dropping a trailing newline

Command substitution strips trailing newlines, so an in-directory symlink pointing at
a regular file whose name ends in one canonicalised to a different path -- which may
itself exist -- and that path then passed the containment check and reached ffmpeg and
the click command. Reading realpath's NUL-terminated output keeps the name intact, and
a resolved path carrying a control character is refused outright.

Not reachable through a yt-dlp download, since --restrict-filenames strips control
characters from the name it writes; it is the helper's contract that was wrong.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Codex XHigh <noreply@openai.com>

* Accept a download directory that resolves to /

realpath returns "/" for the root directory, which made the containment pattern "//*"
and rejected every file saved directly under it, so the host reported a failed download
after saving the file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Codex XHigh <noreply@openai.com>

* Stop refusing a download because its title has control characters

The gate cannot tell a hostile title from a legitimate one. --print emits one line per
extracted video and --no-playlist does not collapse a multi_video result, so a page
holding two clips arrives as two titles separated by a newline and is refused exactly
like a forged record would be.

It also guaranteed nothing it was read as guaranteeing. The simulate run and the
download run are separate fetches, so a site is free to answer them differently, and
the check never constrained the metadata the download actually used.

What stands between a record and the click command is resolve_download_file, which is
untouched here. Leaving a check that refuses valid pages while securing nothing invites
the path validation to be relaxed later on the strength of it. A gate that would work
is possible -- --print '%(title)j' encodes each title as JSON on its own line, which
separates a newline in the metadata from a newline between videos -- but it belongs
with a use for the title rather than as a bare refusal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Codex XHigh <noreply@openai.com>

* Disarm the legacy exec-before-download hook too

--no-exec clears the modern --exec map but leaves --exec-before-download stored
separately, and yt-dlp restores it as a before_dl postprocessor, so a hook configured
in the user's yt-dlp config still ran during the download this host drives.

The accompanying test runs download_url itself against stubbed tools. Everything else
in this file exercises the helpers in isolation, which left the invocation uncovered:
restoring the title to the record template, or dropping --no-exec or the trailing --,
passed every assertion here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Codex XHigh <noreply@openai.com>

* Toast the page title again instead of the saved filename

Deriving the toast text from the sanitised filename cost the title it was meant to
show: "My Great Clip" arrived as "My_Great_Clip [My_Great_Clip]". The title is safe as
notification text -- it is an argv element, never part of a command -- so the only
question was getting it out of yt-dlp without reopening the record forgery.

It now comes from the download run, so it describes the file that was actually saved,
and it is printed as %(title)j. JSON-encoding is what makes that safe: a newline or tab
in page metadata becomes an escape sequence inside one quoted string rather than a
record boundary, so a title can no longer split itself across lines. The decoder keeps
only what precedes the first control character, refuses anything notify-send would read
as an option, and leaves the filename-derived title as the fallback when a page offers
nothing usable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Name the saved file after the page title

The download landed as "My_Great_Clip [My_Great_Clip].mp4" when the page called it
"My Great Clip". --restrict-filenames was carrying more weight than it earns here: it
folds spaces to underscores and strips non-ASCII, which is what mangles the name, and
it is not what keeps the record stream safe. yt-dlp removes control characters from a
filename either way -- a newline becomes a space, tabs and DEL and NUL are dropped --
so a path printed after the move is still only ever one line, which is the property
resolve_download_file depends on.

Dropping the [%(id)s] suffix is the other half of matching the title, and it trades
away the uniqueness that suffix bought: two videos sharing a title now share a name,
and yt-dlp skips a download whose file already exists, so the second one toasts as a
failure. Restoring the suffix is a one-line change if that trade is the wrong way
round.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Omabot <omabot@omarchy.org>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Codex XHigh <noreply@openai.com>
2026-08-23 16:18:38 +02:00
df708831b6 Keep the Copy URL migration's browser prompt visible (#6842)
gum draws its confirm UI on stderr, so the migration's `2>/dev/null` threw
away the whole prompt while gum still held the terminal in raw mode reading
keys. With a browser open, an update stopped after "Running migration
(1786643346)" on an unpainted screen with no way to tell it was waiting for
an answer.

Nothing else in the repo suppresses gum's stderr; the redirect only ever hid
gum's own error in the no-terminal case, where the migration already explains
itself on stderr before deferring.

Fixes #6841


Claude-Session: https://claude.ai/code/session_01So4yKzTDdJJp7orjnF8i1J

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 08:19:25 +02:00