~/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:

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 finding is the shape, not a working attack: a generated-file sink with no escaping is one new caller away from being an injection point. The moment any future feature wires a less-trusted source into webapp creation — a share target, a plugin, a URL handler — the missing escaping silently becomes the vulnerability. Closing the sink now costs a few lines; closing it later costs an advisory.

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:

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

Full patch and discussion: basecamp/omarchy #8473 — submitted 2026-08-26, under review.

< cd ~/basti.net