From 9ece53cede223add78664959a7d807d94f247df2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 17:04:25 +0200 Subject: [PATCH] Prove the web app name guard, and reject before the icon is fetched MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The slash guard was the only thing keeping a name out of the directory structure, and nothing tested it: deleting it left the suite green, because creating the launcher directly in the applications directory already makes the redirect fail on its own, with a raw bash error instead of the message. The assertion is on the message now, alongside the traversal case the guard actually closes -- on quattro a name of `../../../../escaped` writes its launcher clean outside the applications directory. The interactive prompt read the name, fetched the favicon, wrote it and updated the icon cache before the name was ever checked, so a URL typed into the Name field left an icon behind on every attempt. Validating as soon as the name is read covers both paths from one place. Removing by name also scanned unconditionally, so a machine with no applications directory printed a find error where omarchy-remove-gaming-xbox-cloud does not hide stderr. 🤖 Generated by Opus 5 in Claude Code. Co-Authored-By: Claude Opus 5 (1M context) --- bin/omarchy-webapp-install | 22 ++++++---- bin/omarchy-webapp-remove | 2 +- test/shell.d/webapp-name-test.sh | 71 ++++++++++++++++++++++++++++++-- 3 files changed, 83 insertions(+), 12 deletions(-) diff --git a/bin/omarchy-webapp-install b/bin/omarchy-webapp-install index b7985968..d976622d 100755 --- a/bin/omarchy-webapp-install +++ b/bin/omarchy-webapp-install @@ -13,6 +13,18 @@ safe_icon_name() { | sed 's/[^[:alnum:]]\+/-/g; s/^-//; s/-$//' } +require_plain_name() { + # The name becomes a filename. A slash would turn it into directory levels, so + # the launcher lands somewhere omarchy-webapp-remove cannot address and the app + # is stuck in the launcher; a leading ../ leaves the applications directory + # altogether. Refuse rather than silently renaming what the user typed -- most + # often it is a URL entered in the name field. + if [[ $1 == */* ]]; then + echo "App name cannot contain '/': $1" + exit 1 + fi +} + icon_name_from_ref() { local ref="$1" local name @@ -68,6 +80,7 @@ fetch_site_icon() { 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") + require_plain_name "$APP_NAME" APP_URL=$(gum input --prompt "URL> " --placeholder "https://example.com") if [[ ! $APP_URL =~ ^[a-zA-Z][a-zA-Z0-9+.-]*: ]]; then APP_URL="https://$APP_URL" @@ -104,14 +117,7 @@ if [[ -z $APP_NAME || -z $APP_URL ]]; then exit 1 fi -# The name becomes a filename. A slash would turn it into directory levels, so -# the launcher lands somewhere omarchy-webapp-remove cannot address and the app -# is stuck in the launcher. Refuse rather than silently renaming what the user -# typed -- most often it is a URL entered in the name field. -if [[ $APP_NAME == */* ]]; then - echo "App name cannot contain '/': $APP_NAME" - exit 1 -fi +require_plain_name "$APP_NAME" if [[ -z $ICON_REF ]]; then ICON_VALUE=$(safe_icon_name "$APP_NAME") diff --git a/bin/omarchy-webapp-remove b/bin/omarchy-webapp-remove index 303b3a5d..b3244637 100755 --- a/bin/omarchy-webapp-remove +++ b/bin/omarchy-webapp-remove @@ -19,7 +19,7 @@ while IFS= read -r -d '' file; do WEB_APPS+=("$(basename "${file%.desktop}")") WEB_APP_PATHS+=("$file") fi -done < <(find "$DESKTOP_DIR" -name '*.desktop' -print0) +done < <(find "$DESKTOP_DIR" -name '*.desktop' -print0 2>/dev/null) # The launcher matching a chosen name, or empty when nothing was indexed under # it (an app removed between the scan and the pick, say). diff --git a/test/shell.d/webapp-name-test.sh b/test/shell.d/webapp-name-test.sh index cd66d445..903f851a 100644 --- a/test/shell.d/webapp-name-test.sh +++ b/test/shell.d/webapp-name-test.sh @@ -24,16 +24,73 @@ run_remove() { } apps_dir="$tmp_dir/home/.local/share/applications" +icons_dir="$tmp_dir/home/.local/share/icons/hicolor/256x256/apps" # A URL typed into the name field is the reported way in. Every slash used to -# become a directory level, leaving a launcher nothing could address. -if run_install "http://example.test/oops" "https://example.com" hey >/dev/null 2>&1; then +# become a directory level, leaving a launcher nothing could address. Assert on +# the message: creating the launcher directly in the applications directory +# already makes the redirect fail on its own, so a bare non-zero exit would pass +# just as well with no validation at all. +output=$(run_install "http://example.test/oops" "https://example.com" hey 2>&1) && fail "webapp install rejects a name containing a slash" -fi +[[ $output == *"App name cannot contain '/'"* ]] || + fail "webapp install says why it refused a slashed name" "$output" [[ -e "$apps_dir/http:" ]] && fail "webapp install does not create a directory from a slashed name" pass "webapp install rejects a name that would nest the launcher" +# The name was a path fragment until something said otherwise, so ../ climbed +# out of the applications directory entirely and wrote wherever it landed. +if run_install "../../../../escaped" "https://example.com" hey >/dev/null 2>&1; then + fail "webapp install rejects a name that climbs out of the applications directory" +fi +[[ -e "$tmp_dir/escaped.desktop" ]] && + fail "webapp install writes no launcher outside the applications directory" +pass "webapp install refuses a name that would escape the applications directory" + +# The interactive prompt reads the name long before it is used as a path, and +# fetches the site icon in between. Rejecting only at the write leaves that icon +# behind in the user's icon theme, once per attempt. +mkdir -p "$tmp_dir/ibin" +cp "$tmp_dir/bin"/* "$tmp_dir/ibin/" +cat >"$tmp_dir/ibin/gum" <<'STUB' +#!/bin/bash +count_file="${GUM_STUB_COUNT:?}" +count=$(cat "$count_file" 2>/dev/null || echo 0) +count=$((count + 1)) +echo "$count" >"$count_file" +if (( count == 1 )); then + echo "http://example.test/oops" +else + echo "https://example.com" +fi +STUB +cat >"$tmp_dir/ibin/curl" <<'STUB' +#!/bin/bash +# Answer any download with a real PNG so the icon fetch reports success. +out="" +prev="" +for arg in "$@"; do + [[ $prev == "-o" ]] && out="$arg" + prev="$arg" +done +if [[ -n $out ]]; then + printf '%s' 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==' | base64 -d >"$out" +fi +STUB +chmod +x "$tmp_dir/ibin/gum" "$tmp_dir/ibin/curl" + +if HOME="$tmp_dir/home" PATH="$tmp_dir/ibin:$PATH" \ + GUM_STUB_COUNT="$tmp_dir/gum-count" \ + "$ROOT/bin/omarchy-webapp-install" >/dev/null 2>&1; then + fail "interactive webapp install rejects a name containing a slash" +fi +if compgen -G "$icons_dir/*.png" >/dev/null; then + fail "interactive webapp install downloads no icon for a name it refuses" \ + "$(ls "$icons_dir")" +fi +pass "webapp install refuses a slashed name before fetching its icon" + # A normal name still installs and removes. run_install "Example App" "https://example.com" hey >/dev/null [[ -f "$apps_dir/Example App.desktop" ]] || @@ -59,3 +116,11 @@ run_remove "127.0.0.1:4000" >/dev/null [[ -f "$apps_dir/http:/127.0.0.1:4000/.desktop" ]] && fail "webapp remove deletes a launcher left nested by an older install" pass "webapp remove reaches a nested legacy launcher" + +# Removing by name on a machine with no applications directory yet must stay +# quiet: omarchy-remove-gaming-xbox-cloud calls it without hiding stderr. +noise=$(HOME="$tmp_dir/empty" PATH="$tmp_dir/bin:$PATH" OMARCHY_REMOVE_NOTIFY=false \ + "$ROOT/bin/omarchy-webapp-remove" "Xbox Cloud Gaming" 2>&1 >/dev/null) +[[ -n $noise ]] && + fail "webapp remove stays quiet with no applications directory" "$noise" +pass "webapp remove stays quiet when there is no applications directory"