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.
This commit is contained in:
@@ -49,6 +49,38 @@ assert(!notifications.shouldBypassDnd({ appName: 'Slack', urgency: 2 }, 2), 'cri
|
||||
assert(!notifications.shouldBypassDnd({ appName: 'omarchy-menu-keybindings', urgency: 1 }, 2), 'omarchy command app names do not bypass DND')
|
||||
assert(!notifications.isEphemeralApp('omarchy-menu-keybindings'), 'notifications treat omarchy command app names as normal apps')
|
||||
|
||||
// The click action's argv form: parsed from the persisted omarchy-exec-argv
|
||||
// JSON only when it is a non-empty array of strings whose program is present
|
||||
// and not a leading-dash option. Everything else fails closed so a malformed or
|
||||
// hostile hint can never fall through to a shell.
|
||||
assertDeepEqual(
|
||||
notifications.parseExecArgv('["mpv","--","/home/me/a b.mp4"]'),
|
||||
['mpv', '--', '/home/me/a b.mp4'],
|
||||
'notifications parse a valid exec argv vector'
|
||||
)
|
||||
assertEqual(notifications.parseExecArgv(''), null, 'notifications reject an empty exec argv hint')
|
||||
assertEqual(notifications.parseExecArgv('not json'), null, 'notifications reject a non-JSON exec argv hint')
|
||||
assertEqual(notifications.parseExecArgv('"mpv"'), null, 'notifications reject an exec argv hint that is not an array')
|
||||
assertEqual(notifications.parseExecArgv('[]'), null, 'notifications reject an empty exec argv array')
|
||||
assertEqual(notifications.parseExecArgv('["mpv",5]'), null, 'notifications reject a non-string element in the exec argv')
|
||||
assertEqual(notifications.parseExecArgv('["--include=x","y"]'), null, 'notifications reject a leading-dash program in the exec argv')
|
||||
assertEqual(notifications.parseExecArgv('["",""]'), null, 'notifications reject an empty program in the exec argv')
|
||||
|
||||
// The argv vector rides on the snapshot as the raw JSON string, so the model's
|
||||
// value comparison stays a plain string compare and the file round-trip is
|
||||
// lossless.
|
||||
const execSnapshot = notifications.snapshotOf({
|
||||
id: 3,
|
||||
appName: 'omarchy-action',
|
||||
summary: 'Download complete',
|
||||
hints: { 'omarchy-exec-argv': '["mpv","--","/tmp/clip.mp4"]' }
|
||||
}, 1)
|
||||
assertEqual(
|
||||
execSnapshot.execArgv,
|
||||
'["mpv","--","/tmp/clip.mp4"]',
|
||||
'notifications carry the exec argv hint onto the snapshot'
|
||||
)
|
||||
|
||||
assertDeepEqual(
|
||||
notifications.popupPlacement('top', 32, 6),
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user