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.
200 lines
7.1 KiB
Bash
Executable File
200 lines
7.1 KiB
Bash
Executable File
#!/bin/bash
|
|
|
|
# omarchy:summary=Native messaging host: download the URL sent by the yt-dlp Chromium extension
|
|
# omarchy:hidden=true
|
|
|
|
set -euo pipefail
|
|
|
|
SCRIPT_PATH="${BASH_SOURCE[0]}"
|
|
|
|
# The browser launches us without Omarchy's environment, so locate the repo from
|
|
# our own path when OMARCHY_PATH isn't already set. Export it — omarchy-shell (used
|
|
# by omarchy-osd) needs it to find the running shell, and silently no-ops without it.
|
|
export OMARCHY_PATH="${OMARCHY_PATH:-$(cd -- "$(dirname -- "$SCRIPT_PATH")/.." && pwd)}"
|
|
|
|
# Make sure the Omarchy bin and yt-dlp are reachable when launched by the browser.
|
|
export PATH="$OMARCHY_PATH/bin:/usr/local/bin:/usr/bin:$PATH"
|
|
|
|
DOWNLOAD_DIR="${OMARCHY_YTDLP_DIR:-$HOME/Videos}"
|
|
|
|
parse_url() {
|
|
jq -r '.url // empty' 2>/dev/null <<<"$1" || true
|
|
}
|
|
|
|
valid_url() {
|
|
[[ $1 =~ ^https?:// ]]
|
|
}
|
|
|
|
# A printed path is only usable if it is a regular file inside DOWNLOAD_DIR.
|
|
# Forged records (leading-dash mpv options, paths with control chars, or
|
|
# anything that escaped the download directory) must not reach --exec.
|
|
resolve_download_file() {
|
|
local candidate=$1 file_real dir_real
|
|
|
|
[[ -n $candidate ]] || return 1
|
|
[[ $candidate != *$'\n'* && $candidate != *$'\r'* && $candidate != *$'\t'* ]] || return 1
|
|
[[ -f $candidate ]] || return 1
|
|
|
|
# Read to a NUL: command substitution strips trailing newlines, which would
|
|
# resolve a name ending in one to a different file that may well exist.
|
|
IFS= read -r -d '' file_real < <(realpath -ze -- "$candidate") || return 1
|
|
IFS= read -r -d '' dir_real < <(realpath -ze -- "$DOWNLOAD_DIR") || return 1
|
|
|
|
[[ $file_real != *$'\n'* && $file_real != *$'\r'* && $file_real != *$'\t'* ]] || return 1
|
|
# Trim the slash so a download directory of "/" still leaves a usable prefix.
|
|
[[ $file_real == "${dir_real%/}"/* ]] || return 1
|
|
|
|
printf '%s' "$file_real"
|
|
}
|
|
|
|
# yt-dlp prints the title JSON-encoded, so a newline or tab in page metadata is an
|
|
# escape sequence rather than a record boundary. This is toast text, never a command.
|
|
decode_title() {
|
|
local decoded
|
|
|
|
decoded=$(jq -r 'if type == "string" then . else empty end' <<<"$1" 2>/dev/null) || return 1
|
|
decoded=${decoded%%[[:cntrl:]]*} # keep what a person would read, drop the forgery
|
|
[[ -n $decoded && $decoded != -* ]] || return 1
|
|
|
|
printf '%s' "$decoded"
|
|
}
|
|
|
|
title_from_file() {
|
|
local name=${1##*/}
|
|
name=${name%.*}
|
|
name=${name//[$'\n\r\t']/}
|
|
if [[ -z $name || $name == -* ]]; then
|
|
printf '%s' "Video"
|
|
else
|
|
printf '%s' "$name"
|
|
fi
|
|
}
|
|
|
|
# The click action is passed to the shell as an argv vector (--exec-arg), so the
|
|
# path is one literal argument and never reaches a shell. `--` still guards mpv
|
|
# itself against parsing a leading-dash filename as an option.
|
|
playback_exec_args=(--exec-arg mpv --exec-arg -- --exec-arg)
|
|
|
|
# Drive the Quickshell OSD — a single overlay that updates in place (like the
|
|
# volume/brightness bar), so download progress never stacks like notifications.
|
|
osd_progress() {
|
|
omarchy-osd -i -p "$1" -d 8000 >/dev/null 2>&1 || true
|
|
}
|
|
|
|
osd_close() {
|
|
omarchy-shell -q osd close >/dev/null 2>&1 || true
|
|
}
|
|
|
|
download_url() {
|
|
local url="$1"
|
|
|
|
mkdir -p "$DOWNLOAD_DIR"
|
|
|
|
# Don't show anything until yt-dlp confirms there's actually a video to grab.
|
|
if ! yt-dlp --no-playlist --simulate --quiet --no-warnings --no-exec --no-exec-before-download -- "$url" >/dev/null 2>&1; then
|
|
omarchy-notification-send -u critical -g "No video found for download" "$url"
|
|
exit 0
|
|
fi
|
|
|
|
osd_progress 0
|
|
|
|
# Stream the download: OMARCHY_PROG carries the percent (drives the OSD), and
|
|
# OMARCHY_FILE and OMARCHY_TITLE (printed only after a successful move) carry the
|
|
# path and the title. The title is JSON-encoded so metadata cannot forge a record,
|
|
# and the file is named after it: yt-dlp strips control characters from a filename
|
|
# with or without --restrict-filenames, so a record is still only ever one line.
|
|
local line pct intpct last="" er nowms lastms=0 title="" filepath="" resolved
|
|
while IFS= read -r line; do
|
|
case $line in
|
|
OMARCHY_PROG*)
|
|
pct=${line#OMARCHY_PROG$'\t'}
|
|
intpct=${pct%%.*}
|
|
intpct=${intpct//[^0-9]/}
|
|
[[ -n $intpct && $intpct != "$last" ]] || continue # skip no-op repeats
|
|
# Throttle to ~4 redraws/sec so fast downloads don't spawn a flurry of processes.
|
|
er=$EPOCHREALTIME
|
|
nowms=$((${er%[.,]*} * 1000 + 10#${er##*[.,]} / 1000))
|
|
((nowms - lastms >= 250)) || continue
|
|
last=$intpct
|
|
lastms=$nowms
|
|
osd_progress "$intpct"
|
|
;;
|
|
OMARCHY_FILE*)
|
|
resolved=$(resolve_download_file "${line#OMARCHY_FILE$'\t'}") || continue
|
|
filepath=$resolved
|
|
;;
|
|
OMARCHY_TITLE*)
|
|
title=$(decode_title "${line#OMARCHY_TITLE$'\t'}") || title=""
|
|
;;
|
|
esac
|
|
done < <(PYTHONUNBUFFERED=1 yt-dlp --no-playlist --no-simulate \
|
|
--quiet --no-warnings --no-exec --no-exec-before-download --progress --newline \
|
|
--progress-template $'download:OMARCHY_PROG\t%(progress._percent_str)s' \
|
|
--paths "$DOWNLOAD_DIR" -o '%(title)s.%(ext)s' \
|
|
--print $'after_move:OMARCHY_FILE\t%(filepath)s' \
|
|
--print $'after_move:OMARCHY_TITLE\t%(title)j' \
|
|
-- "$url" 2>&1)
|
|
|
|
osd_close
|
|
|
|
# after_move only prints on a successful download+move, so a captured path == success.
|
|
if [[ -n $filepath ]]; then
|
|
[[ -n $title ]] || title=$(title_from_file "$filepath")
|
|
((${#title} > 50)) && title="${title:0:50}…" # keep the toast compact
|
|
|
|
# Square, center-cropped thumbnail so the notification preview isn't stretched.
|
|
local preview
|
|
preview="$(mktemp --suffix=.jpg)"
|
|
ffmpeg -y -i "$filepath" -ss 00:00:00.1 -vframes 1 \
|
|
-vf "crop='min(iw,ih)':'min(iw,ih)',scale=256:256" -q:v 2 \
|
|
"$preview" -loglevel quiet 2>/dev/null || true
|
|
|
|
# Best-effort: the download already succeeded, and under `set -e` a failed
|
|
# toast would exit before the thumbnail cleanup below is ever scheduled.
|
|
omarchy-notification-send -g "Download complete" "$title" \
|
|
-t 10000 --image "${preview:-$filepath}" \
|
|
"${playback_exec_args[@]}" "$filepath" || true
|
|
|
|
# The shell loads the thumbnail into memory when the toast appears and never
|
|
# re-reads the file, so the preview only has to outlive that load, not the
|
|
# toast.
|
|
(
|
|
sleep 2
|
|
rm -f "$preview"
|
|
) &
|
|
else
|
|
omarchy-notification-send -u critical -g "Download failed" "$url"
|
|
fi
|
|
|
|
exit 0
|
|
}
|
|
|
|
main() {
|
|
local length payload url
|
|
|
|
# Detached worker: this is what actually runs yt-dlp and fires notifications.
|
|
if [[ ${1:-} == "--download" ]]; then
|
|
download_url "$2"
|
|
fi
|
|
|
|
# Native messaging frame: 4-byte little-endian length prefix, then UTF-8 JSON.
|
|
length=$(head -c4 | od -An -v -tu4 --endian=little | tr -d ' ')
|
|
[[ -n ${length:-} ]] && ((length > 0)) || exit 0
|
|
|
|
payload=$(head -c "$length")
|
|
|
|
# Ack with an empty message so the extension's sendNativeMessage callback resolves cleanly.
|
|
printf '\x02\x00\x00\x00{}'
|
|
|
|
url=$(parse_url "$payload")
|
|
[[ -n $url ]] || exit 0
|
|
valid_url "$url" || exit 0
|
|
|
|
# Detach the download so this host exits promptly and frees the browser's port.
|
|
setsid -f "$SCRIPT_PATH" --download "$url" </dev/null >/dev/null 2>&1
|
|
}
|
|
|
|
if [[ ${BASH_SOURCE[0]} == "$0" ]]; then
|
|
main "$@"
|
|
fi
|