Files
omarchycn/shell/plugins/notifications/NotificationLogic.js
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

414 lines
15 KiB
JavaScript

function isChromiumDerived(app, appIcon) {
var source = (String(app || "") + "\n" + String(appIcon || "")).toLowerCase()
return source.indexOf("chrom") >= 0 || source.indexOf("brave") >= 0 ||
source.indexOf("vivaldi") >= 0 || source.indexOf("microsoft-edge") >= 0 ||
source.indexOf("opera") >= 0
}
function sanitizeBody(body, app, appIcon) {
var text = String(body || "").replace(/<img[^>]*>/gi, "")
if (!isChromiumDerived(app, appIcon)) return text
return text
.replace(/^\s*<a\b[^>]*>\s*(?:https?:\/\/|www\.)?(?:[a-z0-9-]+\.)+[a-z]{2,}(?::\d+)?(?:\/[^<\s]*)?\s*<\/a>\s*/i, "")
.replace(/^\s*(?:https?:\/\/|www\.)?(?:[a-z0-9-]+\.)+[a-z]{2,}(?::\d+)?(?:\/\S*)?\s+/i, "")
}
function summaryStartsWithGlyph(summary) {
var text = String(summary || "").replace(/^\s+/, "")
if (!text) return false
var offset = 1
var first = text.charCodeAt(0)
if (first >= 0xd800 && first <= 0xdbff && text.length > 1) offset = 2
var spaces = 0
while (offset < text.length && text.charAt(offset) === " ") {
spaces++
offset++
}
return spaces >= 2
}
function shouldBypassDnd(notification, criticalUrgency) {
var appName = String((notification && notification.appName) || "")
if (appName === "omarchy-action") return true
return appName === "notify-send" && notification && notification.urgency === criticalUrgency
}
function isEphemeralApp(appName) {
var name = String(appName || "")
return name === "notify-send" || name === "omarchy-action"
}
function stringHint(hints, name) {
try {
if (hints) {
var value = hints[name]
if (value !== undefined && value !== null) return String(value)
}
} catch (e) {
}
return ""
}
function glyphFromHints(hints) {
return stringHint(hints, "omarchy-glyph")
}
// Shell command to run when the card is clicked, sent by
// omarchy-notification-send --exec. Carrying the action as data means it
// travels with the popup through the persistence files, so a toast restored
// after a shell restart clicks through exactly like a live one. A libnotify
// action can't: its sender is still waiting on an id from a server generation
// that no longer exists.
//
// This is a free-form shell string run through `bash -lc`, so it is safe only
// when every value interpolated into it was shell-quoted perfectly. It is kept
// for compatibility and honored only from Omarchy's own trusted toasts (see
// Service.invokePopupDefault); new senders use --exec-arg / the argv form
// below, which never reaches a shell.
function execFromHints(hints) {
return stringHint(hints, "omarchy-exec")
}
// The click action as an argv vector, sent by omarchy-notification-send
// --exec-arg and carried as a JSON array string in the omarchy-exec-argv hint.
// The shell runs it with Quickshell.execDetached (no shell), so a value that an
// attacker controls — a video title, a filename, a URL — is only ever one
// argument and can never be reparsed as a command. This is the parameterized
// form: the "prepared statement" to execFromHints's string concatenation.
function execArgvFromHints(hints) {
return stringHint(hints, "omarchy-exec-argv")
}
// Validate a persisted omarchy-exec-argv value into an argv the shell may run,
// or null for anything that is not one. A malformed or hostile hint must fail
// closed here rather than fall through to a shell: we require a JSON array of
// strings, non-empty, whose first element (the program) is present and does not
// start with "-" (which would let a forged record smuggle in a leading-dash
// option in the program slot).
function parseExecArgv(value) {
var text = String(value || "")
if (!text) return null
var parsed
try {
parsed = JSON.parse(text)
} catch (e) {
return null
}
if (!Array.isArray(parsed) || parsed.length === 0) return null
for (var i = 0; i < parsed.length; i++) {
if (typeof parsed[i] !== "string") return null
}
if (!parsed[0] || parsed[0].charAt(0) === "-") return null
return parsed
}
function shouldRenderCompactGlyph(glyph, iconSource, singleLineToast) {
return String(glyph || "").length > 0 && String(iconSource || "").length === 0 && !!singleLineToast
}
function snapshotOf(notification, timestamp) {
var n = notification || {}
var id = n.id || 0
var expireTimeout = Number(n.expireTimeout || 0)
if (!isFinite(expireTimeout) || expireTimeout < 0) expireTimeout = 0
return {
id: id,
originalId: id,
app: n.appName || "",
appIcon: n.appIcon || "",
summary: String(n.summary || ""),
body: n.body || "",
image: n.image || "",
glyph: glyphFromHints(n.hints),
exec: execFromHints(n.hints),
execArgv: execArgvFromHints(n.hints),
urgency: n.urgency,
expireTimeout: expireTimeout,
timestamp: timestamp === undefined ? Date.now() : timestamp
}
}
// Everything the popup card draws, and therefore everything an in-place
// update has to write through to the row and its file.
var POPUP_ROLES = ["app", "appIcon", "summary", "body", "image", "glyph", "exec", "execArgv", "urgency", "expireTimeout"]
function popupRoles() {
return POPUP_ROLES
}
// Whether a refresh has anything to write. Each property a client updates
// emits its own signal, and the catch-up refresh after a row is inserted
// usually finds the object exactly as it was snapshotted — without this,
// one update would rewrite the file several times over.
function popupRowChanged(row, updated) {
var current = row || {}
var next = updated || {}
for (var i = 0; i < POPUP_ROLES.length; i++) {
var role = POPUP_ROLES[i]
if (current[role] !== next[role]) return true
}
return false
}
// A client updating a notification through replaces_id keeps the identity of
// the popup it took over: the file name is the timestamp and id the popup was
// first persisted under, and the restore, replace and archive paths all key
// off that name. Only what the card draws comes from the updated object.
function replacementSnapshot(notification, originalId, timestamp) {
var updated = snapshotOf(notification, timestamp)
updated.id = originalId
updated.originalId = originalId
return updated
}
function historyEntry(value, normalUrgency) {
var e = value || {}
return {
id: e.id || 0,
originalId: e.originalId || e.id || 0,
app: e.app || "",
appIcon: e.appIcon || "",
summary: e.summary || "",
body: e.body || "",
image: e.image || "",
glyph: e.glyph || "",
exec: e.exec || "",
execArgv: e.execArgv || "",
urgency: typeof e.urgency === "number" ? e.urgency : normalUrgency,
expireTimeout: 0,
timestamp: e.timestamp || 0
}
}
// notifications.json holds nothing but the last-set DND preference now that
// history is a directory of files. Older versions kept `pending`/`past`
// (and, older still, `entries`) arrays in there; their presence is reported
// so the service can rewrite the file without the dead payload.
function parseSettings(raw) {
var text = String(raw || "").trim()
if (!text) return { error: false, dnd: null, legacy: false }
try {
var parsed = JSON.parse(text)
return {
error: false,
dnd: parsed && typeof parsed.dnd === "boolean" ? parsed.dnd : null,
legacy: !!(parsed && (parsed.pending || parsed.past || parsed.entries))
}
} catch (e) {
return { error: true, errorMessage: String(e), dnd: null, legacy: false }
}
}
// ---------------------------------------------------- popup persistence
//
// Each on-screen popup is mirrored to its own file under
// ~/.local/state/omarchy/notifications/ so toasts survive shell restarts
// (e.g. the restart `omarchy-update` performs). The file exists exactly as
// long as the popup is on screen: it is written when the toast appears and
// moved into the history/ subdirectory when the toast expires, is dismissed,
// or its action is invoked. History is those moved files, newest last-10.
function popupEntry(value, normalUrgency) {
var entry = historyEntry(value, normalUrgency)
var expire = Number((value || {}).expireTimeout || 0)
if (!isFinite(expire) || expire < 0) expire = 0
entry.expireTimeout = expire
// Absolute expiry deadline, set only when a restore resets a surviving
// popup's display lifetime. Kept out of the entry entirely when unset so
// restored rows match the roles of freshly received ones.
var deadline = Number((value || {}).deadline || 0)
if (isFinite(deadline) && deadline > 0) entry.deadline = deadline
return entry
}
function popupFileName(entry) {
return imageStem(entry) + ".json"
}
// ---------------------------------------------------- persisted images
//
// A notification's images only exist while it is live: Chromium-family
// senders (all Omarchy web apps) delete their scoped /tmp files on close,
// and image-data hints surface as in-process image:// URLs that die with
// the server object. Persisted entries therefore reference their own
// copies, named by the entry's file stem so cleanup can find them from
// the JSON file name alone.
var PERSISTED_IMAGE_ROLES = ["appIcon", "image"]
function imageStem(entry) {
var e = entry || {}
return String(e.timestamp || 0) + "-" + String(e.originalId || 0)
}
// The filesystem path behind a file-backed image value, or "" for anything
// a copy can't capture: themed icon names, in-process image:// URLs, empty.
function localImageFile(value) {
var s = String(value || "")
if (s.indexOf("file://") === 0) {
s = s.slice(7)
try { s = decodeURIComponent(s) } catch (e) {}
}
return s.charAt(0) === "/" ? s : ""
}
// The entry as it should hit the disk, plus the copies that make it true.
// File-backed images redirect to their copy under imagesDir; dead image://
// URLs drop to "" (the card falls back to the app icon). Already-redirected
// values map onto themselves and produce no copy, keeping restores no-ops.
function persistablePopup(entry, imagesDir) {
var e = entry || {}
var out = {}
for (var key in e) out[key] = e[key]
var copies = []
for (var i = 0; i < PERSISTED_IMAGE_ROLES.length; i++) {
var role = PERSISTED_IMAGE_ROLES[i]
var value = String(out[role] || "")
if (!value) continue
var source = localImageFile(value)
if (source) {
var copy = String(imagesDir || "") + imageStem(e) + "-" + role
if (source !== copy) copies.push({ from: source, to: copy })
out[role] = "file://" + copy
} else if (value.indexOf("image://") === 0) {
out[role] = ""
}
}
return { entry: out, copies: copies }
}
function serializePopup(entry, normalUrgency) {
// Compact (single-line) on purpose: restore cats every file together and
// parses line by line, which only works when each file is one line.
return JSON.stringify(popupEntry(entry, normalUrgency))
}
// Parse the concatenation of every persisted popup file into entries,
// newest-first. Deliberately NO dedupe by originalId: ids restart from 1
// with every server process, so two files sharing an id are usually
// different generations — dropping the older one would silently discard a
// restored critical alert the moment a fresh notification reuses its id.
// The one case that leaves a genuine duplicate (a crash between a
// replacement's write and the replaced file's delete) merely re-shows a
// superseded toast, which expires or is dismissed and cleans itself up.
function parsePopupFiles(raw, normalUrgency) {
var lines = String(raw || "").split("\n")
var entries = []
for (var i = 0; i < lines.length; i++) {
var line = lines[i].trim()
if (!line) continue
try {
var value = JSON.parse(line)
if (value && typeof value === "object") entries.push(popupEntry(value, normalUrgency))
} catch (e) {
// A torn write from a crash mid-save — skip the line, keep the rest.
}
}
entries.sort(function(a, b) { return (b.timestamp || 0) - (a.timestamp || 0) })
return entries
}
// A persisted popup whose lifetime already ran out would have expired on
// screen had the shell kept running, so it is not restored. duration 0 means
// the popup never expires (critical urgency) and always survives restarts.
// A restore-reset deadline outranks the original timestamp: without it, a
// second restart would judge a re-shown toast by a clock that no longer
// governs its display and drop it while it is still on screen.
function popupExpired(entry, duration, now) {
var deadline = Number((entry || {}).deadline || 0)
if (isFinite(deadline) && deadline > 0) return Number(now) >= deadline
var lifetime = Number(duration || 0)
if (!isFinite(lifetime) || lifetime <= 0) return false
return (Number(now) - Number((entry || {}).timestamp || 0)) >= lifetime
}
function popupPlacement(barPosition, barClearance, gapsOut) {
var position = String(barPosition || "top")
var clearance = Number(barClearance)
var gap = Number(gapsOut)
if (!isFinite(clearance)) clearance = 0
if (!isFinite(gap)) gap = 0
return {
anchors: { top: true, bottom: false, left: false, right: true },
margins: {
top: position === "top" ? clearance : gap,
bottom: gap,
left: gap,
right: position === "right" ? clearance : gap
}
}
}
// The archived files are the history. They are read back exactly like the
// live popup files, then normalized into history rows: replaying a toast
// must not inherit the original's expire timeout or restore deadline, so it
// gets the standard on-screen lifetime for its urgency instead.
//
// liveRows are the toasts still on screen when the replay was asked for.
// They belong in it — they're the newest notifications there are — but the
// directory read races their archival, so they're carried across by hand and
// keyed by file name (timestamp + id) to drop the copy the read already saw.
function historyRows(raw, liveRows, normalUrgency, limit) {
var max = limit === undefined || limit === null ? 10 : Number(limit)
if (isNaN(max)) max = 10
max = Math.max(0, max)
var out = []
var seen = {}
function collect(rows) {
for (var i = 0; i < rows.length; i++) {
var entry = rows[i]
if (!entry) continue
var key = popupFileName(entry)
if (seen[key]) continue
seen[key] = true
out.push(historyEntry(entry, normalUrgency))
}
}
collect(Array.isArray(liveRows) ? liveRows : [])
collect(parsePopupFiles(raw, normalUrgency))
out.sort(function(a, b) { return (b.timestamp || 0) - (a.timestamp || 0) })
return out.slice(0, max)
}
if (typeof module !== "undefined") {
module.exports = {
isChromiumDerived: isChromiumDerived,
sanitizeBody: sanitizeBody,
summaryStartsWithGlyph: summaryStartsWithGlyph,
shouldBypassDnd: shouldBypassDnd,
isEphemeralApp: isEphemeralApp,
stringHint: stringHint,
glyphFromHints: glyphFromHints,
execFromHints: execFromHints,
execArgvFromHints: execArgvFromHints,
parseExecArgv: parseExecArgv,
shouldRenderCompactGlyph: shouldRenderCompactGlyph,
snapshotOf: snapshotOf,
popupRoles: popupRoles,
popupRowChanged: popupRowChanged,
replacementSnapshot: replacementSnapshot,
historyEntry: historyEntry,
parseSettings: parseSettings,
historyRows: historyRows,
popupEntry: popupEntry,
popupFileName: popupFileName,
imageStem: imageStem,
localImageFile: localImageFile,
persistablePopup: persistablePopup,
serializePopup: serializePopup,
parsePopupFiles: parsePopupFiles,
popupExpired: popupExpired,
popupPlacement: popupPlacement
}
}