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.
414 lines
15 KiB
JavaScript
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
|
|
}
|
|
}
|