From 29886e00273127c228054e2b0097e12fe132c07f Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Tue, 15 Sep 2026 18:15:50 +0200 Subject: fix(notifications): allowlist local inline image sources The http(s)-only regex let protocol-relative //host, ftp, data and entity-encoded schemes through, and the comment overstated what it removed. Replace it with a deny-by-default allowlist: an is removed unless its src, entities decoded first, is a file: URL or a single leading slash. Protocol-relative //host is rejected while a /absolute/path is kept. --- shared/Notify.qml | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) (limited to 'shared') diff --git a/shared/Notify.qml b/shared/Notify.qml index c79f5f5..237d8c9 100644 --- a/shared/Notify.qml +++ b/shared/Notify.qml @@ -66,13 +66,30 @@ Singleton { function close(id) { root.run(["close", String(id)]); } function closeAll() { root.run(["close-all"]); } - // Inline images are local only. A notification is untrusted input, and a - // remote would otherwise make the shell fetch a URL, which leaks - // that the notification was shown. This removes such tags before the - // RichText body renders; a local path or file:// source is left alone. + // Inline images are local only. A notification is untrusted input, and any + // remote (http(s), protocol-relative //host, ftp, data, or an + // entity-encoded scheme) would make the shell fetch or embed something the + // sender chose, which leaks that the notification was shown. Deny by + // default: a tag survives only if its src decodes to a file: URL or a + // leading-slash absolute path. Entities are decoded before the test, so an + // encoded scheme cannot slip past. function sanitize(body) { - return (body || "").replace(/]*\bsrc\s*=\s*["']?\s*https?:\/\/[^>]*>/gi, ""); + function decode(s) { + return s.replace(/&(?:#x([0-9a-f]+)|#(\d+)|(amp|colon|sol|tab|quot));/gi, + function (m, hex, dec, name) { + if (hex !== undefined) return String.fromCharCode(parseInt(hex, 16)); + if (dec !== undefined) return String.fromCharCode(parseInt(dec, 10)); + return { amp: "&", colon: ":", sol: "/", tab: "\t", quot: "\"" }[name.toLowerCase()]; + }); + } + return (body || "").replace(/]*>/gi, function (tag) { + const m = tag.match(/\bsrc\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i); + if (!m) return ""; + const src = decode(m[1] !== undefined ? m[1] : m[2] !== undefined ? m[2] : m[3]); + return /^file:/i.test(src) || (src.charAt(0) === "/" && src.charAt(1) !== "/") ? tag : ""; + }); } + function action(id, key) { root.run(["action", String(id), key]); } function clearHistory() { root.run(["clear-history"]); } -- cgit v1.2.3