~/research/2026-08-26
Escaping webapp .desktop values per the freedesktop spec
tl;dr
Omarchy's
omarchy-webapp-install generates a .desktop launcher file and
writes every substituted value — Name, Comment, Icon,
MimeType, Exec — into it raw, with no escaping. The
.desktop format is line-oriented: a raw newline inside a value starts a
new key line, so a newline in an app name could inject a second Exec=
directive into the file. My patch adds the two escaping layers the freedesktop
Desktop Entry spec actually defines — string-type escaping for every field, and
Exec-argument quoting for the URL — and verifies the launch path round-trips
byte-exactly. No live exploit today: this is defense-in-depth for a latent sink,
currently under review upstream.
background: what webapp-install does
Omarchy is DHH's opinionated Arch-based Linux distribution, developed on GitHub
under the basecamp organization. omarchy-webapp-install turns a website
into a desktop app: it takes a name, a URL, and an icon (from the command line or an
interactive gum prompt), then writes a
~/.local/share/applications/<name>.desktop file whose
Exec= line launches the site via omarchy-launch-webapp.
The file is produced by a shell heredoc that interpolates the values directly:
Name=$APP_NAME, Exec=$EXEC_COMMAND, and so on. Whatever
characters the values contain land in the file verbatim.
the attack surface: a line-oriented format with two escaping layers
A .desktop file looks trivially simple — Key=Value lines —
but the freedesktop Desktop Entry spec defines real structure with two distinct
escaping layers, and both were missing:
-
String-type values must escape backslash, tab, CR, and LF
(
\\,\t,\r,\n). Because the format is line-oriented, a raw newline doesn't stay inside the value — it terminates the line and whatever follows is parsed as a new key. A name likeMy App\nExec=evilbecomes a secondExec=directive. -
Exec values carry a second grammar on top: the line is split into
arguments,
%introduces field codes (%f,%u, …), and an argument containing spaces or reserved characters must be double-quoted with its own backslash rules. An unquoted URL with a space splits into two arguments; a literal%gets eaten as a field code.
The two layers compose: the launcher unescapes the file syntax first, then parses the Exec grammar. Getting only one layer right still corrupts values on the way through.
why this is not a live vulnerability
I checked where the values actually come from before writing anything. Today,
untrusted input does not reach this sink: the values originate from Omarchy's own
hardcoded literals (only the Sunshine service passes a custom Exec, and
it's a fixed string), from interactive gum input typed by the user, or
from a direct CLI invocation. No theme, plugin, or scraped remote value feeds them.
No exploit, no CVE theater.
the fix
Two small escapers, applied field-selectively — because the spec's two layers are different grammars, not one:
desktop_string_escape() {
# Desktop Entry "string" value (freedesktop Desktop Entry Spec, "Value types"):
# a raw newline would start a new key line and let a value inject a second
# Exec=. Escape backslash first, then tab/CR/LF and a leading space. Every value
# written into the .desktop file passes through here.
printf '%s' "$1" \
| sed -e ':a;N;$!ba' \
-e 's/\\/\\\\/g' -e 's/\t/\\t/g' -e 's/\r/\\r/g' -e 's/\n/\\n/g' -e 's/^ /\\s/'
}
desktop_exec_arg() {
# One Exec argument, double-quoted per the freedesktop Exec spec: inside quotes
# " ` $ \ take a backslash and a literal % becomes %%. Only the default Exec's
# URL needs this; $CUSTOM_EXEC stays a whole command line (file-syntax only).
local escaped
escaped=$(printf '%s' "$1" \
| sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' -e 's/`/\\`/g' -e 's/\$/\\$/g' -e 's/%/%%/g')
printf '"%s"' "$escaped"
}
Every value written into the file passes through desktop_string_escape.
The default Exec's URL additionally goes through desktop_exec_arg, so it
is emitted as exactly one double-quoted Exec argument — spaces, %, and
reserved characters included.
The deliberate exception is worth spelling out: $CUSTOM_EXEC gets
file-syntax escaping only. It is a full command line by design — quoting it
as a single argument would break every caller that passes one. Escaping is not a
blanket you throw over everything; it's applied per field, per grammar.
proving it: the launch path round-trips
Escaping bugs hide at the boundary between writer and reader, so verification runs the real reader:
-
The generated
.desktopfile contains exactly one escaped key per field — no value manages to smuggle in an extra line. -
gio launchhands the URL toomarchy-launch-webappas a single, unchanged argument — verified with a normal URL, and with an adversarial value: an embedded newline is neutralized, and a URL carrying a space plus%round-trips exactly. -
The Sunshine service — the one real
CUSTOM_EXECcaller — produces a byte-identicalExec=line before and after the patch. Hardening that changes existing behavior is a regression, not a fix.
Related but deliberately out of scope: $APP_NAME also flows unescaped
into the .desktop filename — a separate path-handling concern
being addressed in
#7984
(still open).
This patch only escapes values written into the file's content.
takeaways
- Generated files are parsers in reverse. If a format has escaping rules, a writer that skips them is an injection sink — whether or not anyone hostile can reach it yet.
- Know your grammar's layers. Desktop Entry files have two (file syntax, then Exec syntax), and they compose in a defined order. One escaper for both would be wrong in each direction.
-
Escape per field, not per file.
$CUSTOM_EXECis a command line, not a value — quoting it as data would break it. Correct hardening knows where not to apply itself. -
Verify with the real consumer. "The file looks right" is weaker
than "
gio launchdelivers the exact argument" — and the no-regression check on the one real caller is stated up front, same honest-severity approach that got the earlier patches through review.
Full patch and discussion: basecamp/omarchy #8473 — submitted 2026-08-26, under review.
< cd ~/basti.net