From 187c268d68b8ff1a0599236fb989d0f3e0446348 Mon Sep 17 00:00:00 2001 From: bastidotnet <233381911+bastidotnet@users.noreply.github.com> Date: Wed, 26 Aug 2026 23:55:47 +0200 Subject: [PATCH 1/2] Escape webapp .desktop values per freedesktop spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - omarchy-webapp-install wrote all substituted values raw into the generated .desktop (Name/Comment/Icon/MimeType/Exec), with no escaping. - Adds two spec-level escapers: Desktop Entry string escaping on every field (a raw newline could inject a second key line / second Exec=), and Exec-argument quoting for the default Exec's URL (spaces, %, reserved chars). $CUSTOM_EXEC stays file-syntax-only — it is a full command line by design, not a single value. - No known exploit path: untrusted input reaches these values today only via Omarchy literals, interactive gum, or direct CLI. This is defense-in-depth for a latent sink. - Verified end-to-end: generated .desktop has one escaped key per field; gio launch passes the URL to omarchy-launch-webapp as a single unchanged argument (Sunshine literal and normal gum path both byte-identical). --- bin/omarchy-webapp-install | 44 ++++++++++++++++++++++++++++++++------ 1 file changed, 37 insertions(+), 7 deletions(-) diff --git a/bin/omarchy-webapp-install b/bin/omarchy-webapp-install index acfdf858..e4f0039d 100755 --- a/bin/omarchy-webapp-install +++ b/bin/omarchy-webapp-install @@ -65,6 +65,26 @@ fetch_site_icon() { download_icon "https://www.google.com/s2/favicons?domain=${site_url}&sz=256" "$dest" } +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" +} + if (( $# < 3 )); then echo -e "\e[32mLet's create a new web app you can start with the app launcher.\n\e[0m" APP_NAME=$(gum input --prompt "Name> " --placeholder "My favorite web app") @@ -128,28 +148,38 @@ else ICON_VALUE=$(icon_name_from_ref "$ICON_REF") fi -# Use custom exec if provided, otherwise default behavior -EXEC_COMMAND="${CUSTOM_EXEC:-omarchy-launch-webapp $APP_URL}" +# Default Exec quotes the URL as one Exec-spec argument; the whole line then gets +# the file-syntax escaping below (unescaped first at read time per spec, so the +# layers compose). $CUSTOM_EXEC is a full command line, so it gets file-syntax only. +if [[ -n $CUSTOM_EXEC ]]; then + EXEC_COMMAND=$CUSTOM_EXEC +else + EXEC_COMMAND="omarchy-launch-webapp $(desktop_exec_arg "$APP_URL")" +fi # Create application .desktop file DESKTOP_FILE="$HOME/.local/share/applications/$APP_NAME.desktop" mkdir -p "$(dirname "$DESKTOP_FILE")" +name_field=$(desktop_string_escape "$APP_NAME") +exec_field=$(desktop_string_escape "$EXEC_COMMAND") +icon_field=$(desktop_string_escape "$ICON_VALUE") + cat >"$DESKTOP_FILE" <>"$DESKTOP_FILE" + printf 'MimeType=%s\n' "$(desktop_string_escape "$MIME_TYPES")" >>"$DESKTOP_FILE" fi chmod +x "$DESKTOP_FILE" From c2587dff086f894055653a047796e5ec19727f3e Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Thu, 27 Aug 2026 19:45:45 +0200 Subject: [PATCH 2/2] Escape .desktop values with parameter expansion, not sed GNU sed's N auto-prints the pattern space and exits at end of input, so the `:a;N;$!ba` slurp skipped every following s/// for a value with no newline in it. That is every value except the injection attempt the escaping exists to stop, so the Exec quoting's own backslashes were written to the file unescaped and GLib refused to load the entry. Co-Authored-By: Claude Opus 5 (1M context) Co-Authored-By: Codex GPT-5.6-sol (xhigh) --- bin/omarchy-webapp-install | 17 +++- test/shell.d/webapp-install-escaping-test.sh | 92 ++++++++++++++++++++ 2 files changed, 106 insertions(+), 3 deletions(-) create mode 100755 test/shell.d/webapp-install-escaping-test.sh diff --git a/bin/omarchy-webapp-install b/bin/omarchy-webapp-install index e4f0039d..f95d9ff2 100755 --- a/bin/omarchy-webapp-install +++ b/bin/omarchy-webapp-install @@ -70,9 +70,20 @@ desktop_string_escape() { # 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/' + # + # Parameter expansion rather than sed: GNU sed's N auto-prints the pattern space + # and exits at end of input, so a `:a;N;$!ba` slurp skips every following s/// + # for a value with no newline in it - which is every value except the injection + # attempt this exists to stop. + local value="$1" + + value=${value//\\/\\\\} + value=${value//$'\t'/\\t} + value=${value//$'\r'/\\r} + value=${value//$'\n'/\\n} + [[ $value == " "* ]] && value="\\s${value# }" + + printf '%s' "$value" } desktop_exec_arg() { diff --git a/test/shell.d/webapp-install-escaping-test.sh b/test/shell.d/webapp-install-escaping-test.sh new file mode 100755 index 00000000..6f1e0c70 --- /dev/null +++ b/test/shell.d/webapp-install-escaping-test.sh @@ -0,0 +1,92 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +require_command gio + +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT + +mock_bin="$test_tmp/bin" +mkdir -p "$mock_bin" + +cat >"$mock_bin/omarchy-launch-webapp" <<'SH' +#!/bin/bash +printf '%s\n' "$@" >>"$OMARCHY_TEST_ARGV" +SH +chmod +x "$mock_bin"/* + +export HOME="$test_tmp/home" +export PATH="$mock_bin:$PATH" +export OMARCHY_TEST_ARGV="$test_tmp/argv" + +applications="$HOME/.local/share/applications" + +install_webapp() { + bash "$ROOT/bin/omarchy-webapp-install" "$@" >/dev/null +} + +desktop_value() { + sed -n "s/^$2=//p" "$1" | head -1 +} + +# gio launch returns before the entry it spawned has run, so poll for the argv the +# stub records rather than reading the log once. +launched_argument() { + local file="$1" attempt + + : >"$OMARCHY_TEST_ARGV" + gio launch "$file" >/dev/null 2>&1 || return 1 + for ((attempt = 0; attempt < 200; attempt++)); do + [[ -s $OMARCHY_TEST_ARGV ]] && break + sleep 0.01 + done + + head -1 "$OMARCHY_TEST_ARGV" +} + +# The Exec quoting escapes a dollar sign with a backslash, and the file syntax has +# to escape that backslash in turn. Left single, GLib reads \$ as an invalid escape +# and refuses the whole entry, so the web app vanishes from the launcher. +install_webapp 'Dollar App' 'https://example.com/a$b' someicon +dollar_file="$applications/Dollar App.desktop" + +[[ -f $dollar_file ]] || fail "web app install writes a desktop entry" + +[[ $(desktop_value "$dollar_file" Exec) == 'omarchy-launch-webapp "https://example.com/a\\$b"' ]] || + fail "Exec escapes the backslash its own quoting introduced" "$(desktop_value "$dollar_file" Exec)" +pass "Exec escapes the backslash its own quoting introduced" + +[[ $(launched_argument "$dollar_file") == 'https://example.com/a$b' ]] || + fail "a URL containing a dollar sign reaches the browser unchanged" +pass "a URL containing a dollar sign reaches the browser unchanged" + +# An unescaped % is read as a Desktop Entry field code and eaten, so ?q=a%20b used +# to arrive as ?q=a0b. +install_webapp 'Percent App' 'https://example.com/s?q=a%20b' someicon +percent_file="$applications/Percent App.desktop" + +[[ $(launched_argument "$percent_file") == 'https://example.com/s?q=a%20b' ]] || + fail "a percent-encoded URL reaches the browser unchanged" +pass "a percent-encoded URL reaches the browser unchanged" + +# A lone backslash is not a Desktop Entry escape sequence, so GLib cannot interpret +# a value that contains one. +install_webapp 'Back\slash App' 'https://example.com' someicon +backslash_file="$applications/Back\slash App.desktop" + +[[ $(desktop_value "$backslash_file" Name) == 'Back\\slash App' ]] || + fail "a backslash in the app name is escaped" "$(desktop_value "$backslash_file" Name)" +pass "a backslash in the app name is escaped" + +# The property the escaping exists for: a newline in a value must not be able to +# start a second key line. +inject_name=$(printf 'Inject\nExec=evil') +install_webapp "$inject_name" 'https://example.com' someicon +inject_file="$applications/$inject_name.desktop" + +(( $(grep -c '^Exec=' "$inject_file") == 1 )) || + fail "a newline in the app name cannot inject a second Exec" "$(cat "$inject_file")" +pass "a newline in the app name cannot inject a second Exec"