Files
omarchycn/bin/omarchy-notification-send
T
Ryan Hughes 07443f3970 Run notification click actions as argv, not shell strings
The click action of a notification was a free-form shell string run through
`bash -lc`, safe only when every sender shell-quoted every interpolated value
perfectly. One slip is RCE: a hostile yt-dlp video title forged an output
record and injected an mpv option into the click command (mehmetince.net RCE,
partially addressed by #7847).

Add a parameterized transport: omarchy-notification-send gains --exec-arg
(repeatable), encoding a JSON argv into the omarchy-exec-argv hint. The shell
runs it with Quickshell.execDetached(argv) and no shell, so data an attacker
controls is only ever one argument and can never be reparsed as a command. The
shell fails closed on a malformed argv hint.

The legacy free-form --exec string is retained but honored only from Omarchy's
own omarchy-action toasts, and deprecated. Migrate all in-repo callers
(screenshot, screen recording, taildrop receive, migrate-notify, crash-watch,
yt-dlp host) to --exec-arg. Update docs and tests.
2026-08-23 12:00:03 -04:00

149 lines
3.7 KiB
Bash
Executable File

#!/bin/bash
# omarchy:summary=Send an Omarchy desktop notification
# omarchy:args=[--exec-arg <arg>]... [--exec <command>] [--app-name <app-name>] [-g <glyph>] [-u <low|normal|critical>] [--image <path-or-uri>] <headline> [description] [notify-send options]
# omarchy:examples=omarchy notification send "Reminder" "5 minutes are up" -g 󰢌
set -euo pipefail
headline=""
description=""
glyph=
urgency="low"
app_name="omarchy-action"
image=
exec_command=
exec_args=()
args=()
parsed_option_args=0
parse_omarchy_option() {
case $1 in
-g | --glyph)
if (($# < 2)); then
echo "Missing value for $1" >&2
exit 1
fi
glyph=$2
parsed_option_args=2
return 0
;;
-u | --urgency)
if (($# < 2)); then
echo "Missing value for $1" >&2
exit 1
fi
urgency="$2"
parsed_option_args=2
return 0
;;
--app-name)
if (($# < 2)); then
echo "Missing value for $1" >&2
exit 1
fi
app_name=$2
parsed_option_args=2
return 0
;;
--image)
if (($# < 2)); then
echo "Missing value for $1" >&2
exit 1
fi
image=$2
parsed_option_args=2
return 0
;;
--exec)
if (($# < 2)); then
echo "Missing value for $1" >&2
exit 1
fi
exec_command=$2
parsed_option_args=2
return 0
;;
--exec-arg)
if (($# < 2)); then
echo "Missing value for $1" >&2
exit 1
fi
# Each --exec-arg contributes one literal argument to the click command.
# The shell runs the resulting argv vector directly (no shell), so callers
# pass untrusted data as its own --exec-arg rather than quoting it into a
# command string. Value is taken verbatim, even when it starts with "-".
exec_args+=("$2")
parsed_option_args=2
return 0
;;
esac
return 1
}
while (($# > 0)); do
if parse_omarchy_option "$@"; then
shift "$parsed_option_args"
else
break
fi
done
if (($# < 1)); then
echo "Usage: omarchy-notification-send [--exec-arg <arg>]... [--exec <command>] [--app-name <app-name>] [-g <glyph>] [-u <low|normal|critical>] [--image <path-or-uri>] <headline> [description] [notify-send options]"
exit 1
fi
headline=$1
shift
if (($# > 0)) && [[ $1 != -* ]]; then
description=$1
shift
fi
while (($# > 0)); do
if parse_omarchy_option "$@"; then
shift "$parsed_option_args"
else
args+=("$1")
shift
fi
done
# Tag as a user-action toast so it pops through DND.
args+=("-a" "$app_name" "-u" "$urgency")
if [[ -n $glyph ]]; then
args+=("--hint=string:omarchy-glyph:$glyph")
fi
if [[ -n $image ]]; then
args+=("--hint=string:image-path:$image")
fi
# The shell runs the click command itself, from a copy it keeps alongside the
# on-screen popup. A libnotify action would instead keep this process blocked
# until the click, and die unanswered whenever the shell restarts underneath it.
#
# --exec-arg builds an argv vector the shell runs directly, so a value carrying
# untrusted data is only ever one argument and never reaches a shell. It wins
# over the legacy free-form --exec string, which is run through `bash -lc` and
# is only safe when the caller quoted every interpolated value itself.
if ((${#exec_args[@]} > 0)); then
# NUL-delimit the args into jq so every byte survives as data — jq's own
# --args would eat a bare "--", and a title with a newline must stay one
# element, not split the vector.
exec_argv_json=$(printf '%s\0' "${exec_args[@]}" | jq -Rsc 'split("\u0000")[:-1]')
args+=("--hint=string:omarchy-exec-argv:$exec_argv_json")
elif [[ -n $exec_command ]]; then
args+=("--hint=string:omarchy-exec:$exec_command")
fi
if [[ -n $description ]]; then
notify-send "${args[@]}" "$headline" "$description"
else
notify-send "${args[@]}" "$headline"
fi