From 51d468fbccc08d8d9e1555094a1515a319bce0f6 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Sun, 28 Jun 2026 12:35:33 -0400 Subject: [PATCH] fix(security,notifications): pre class allowlist, notification privacy + icon, sync-script safety (N100/N106/N109/N119) - N100: restrict
 classes to language-* in sanitize-html allowedClasses;
  previously `class` was allowed on 
 with no allowedClasses entry, so a
  remote sender could inject arbitrary class names that activate site CSS.
- N106: OS notifications for E2EE rooms no longer carry decrypted plaintext
  (which persists in the OS notification center / lock screen). Encrypted rooms
  show only the sender; the in-page toast still previews while focused.
- N109: OS notification icon/badge use the static app logo instead of an
  authenticated-media avatar URL the OS can't fetch (was 401 / no icon). The
  in-app toast keeps the real room avatar (it can fetch via the SW).
- N119: syncDecorations.mjs distinguishes a confirmed 404 (remove) from a
  network/5xx failure (abort) so a transient CDN outage can't silently wipe the
  whole decoration catalog from source control.

Co-Authored-By: Claude Opus 4.8 
---
 scripts/syncDecorations.mjs                  | 25 ++++++++++++++++++--
 src/app/pages/client/ClientNonUIFeatures.tsx | 16 ++++++++++---
 src/app/utils/sanitize.ts                    |  5 ++++
 3 files changed, 41 insertions(+), 5 deletions(-)

diff --git a/scripts/syncDecorations.mjs b/scripts/syncDecorations.mjs
index f4969b6ad..ba1e0afdf 100644
--- a/scripts/syncDecorations.mjs
+++ b/scripts/syncDecorations.mjs
@@ -56,7 +56,8 @@ async function headCheck(slug) {
     const res = await fetch(`${CDN}/${slug}.png`, { method: 'HEAD' });
     return { slug, ok: res.ok, status: res.status };
   } catch {
-    return { slug, ok: false, status: 0 };
+    // Network/DNS/TLS failure — NOT a confirmation the file is gone.
+    return { slug, ok: false, status: 0, networkError: true };
   }
 }
 
@@ -68,7 +69,27 @@ for (let i = 0; i < slugMatches.length; i += BATCH) {
   results.push(...batchResults);
 }
 
-const missing = results.filter((r) => !r.ok);
+// Only a CONFIRMED HTTP 404 means the file is genuinely gone and safe to
+// remove. A network error or any other non-ok status (5xx, 403, timeout) is
+// ambiguous — the CDN may be unreachable — so refuse to remove anything and
+// abort, otherwise a transient outage would wipe the whole catalog from source
+// control (N119).
+const transient = results.filter((r) => !r.ok && r.status !== 404);
+if (transient.length > 0) {
+  console.error(
+    `Aborting: ${transient.length} decoration(s) returned a non-404 failure ` +
+      `(network error / server error). The CDN may be unreachable — refusing to ` +
+      `remove entries to avoid wiping the catalog.`,
+  );
+  transient
+    .slice(0, 8)
+    .forEach((r) =>
+      console.error(`  ${r.slug}: ${r.networkError ? 'network error' : `HTTP ${r.status}`}`),
+    );
+  process.exit(1);
+}
+
+const missing = results.filter((r) => r.status === 404);
 const found = results.filter((r) => r.ok);
 
 if (missing.length === 0) {
diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx
index 5517cdb24..dd4674785 100644
--- a/src/app/pages/client/ClientNonUIFeatures.tsx
+++ b/src/app/pages/client/ClientNonUIFeatures.tsx
@@ -242,6 +242,7 @@ function MessageNotifications() {
       roomId,
       eventId,
       body,
+      encrypted,
     }: {
       roomName: string;
       roomAvatar?: string;
@@ -249,6 +250,7 @@ function MessageNotifications() {
       roomId: string;
       eventId: string;
       body?: string;
+      encrypted?: boolean;
     }) => {
       const roomPath = mDirects.has(roomId)
         ? getDirectRoomPath(roomId, eventId)
@@ -267,10 +269,17 @@ function MessageNotifications() {
         return;
       }
 
+      // N109: the OS notification subsystem fetches icon/badge OUTSIDE the page,
+      // so the SW can't inject auth headers and authenticated-media URLs 401.
+      // Use the static app logo (as invite notifications already do).
+      // N106: never put decrypted E2EE plaintext into the OS notification (it
+      // persists in the notification center / lock screen / is readable by other
+      // apps). For encrypted rooms show only the sender; the in-page toast above
+      // still shows the preview while the user is actively looking at the screen.
       const noti = new window.Notification(roomName, {
-        icon: roomAvatar,
-        badge: roomAvatar,
-        body: body ? `${username}: ${body}`.slice(0, 120) : username,
+        icon: LogoSVG,
+        badge: LogoSVG,
+        body: !encrypted && body ? `${username}: ${body}`.slice(0, 120) : username,
         silent: true,
       });
 
@@ -341,6 +350,7 @@ function MessageNotifications() {
             roomId: room.roomId,
             eventId,
             body: (mEvent.getContent().body as string | undefined) ?? '',
+            encrypted: room.hasEncryptionStateEvent(),
           });
         }
 
diff --git a/src/app/utils/sanitize.ts b/src/app/utils/sanitize.ts
index 82de8efab..490de47aa 100644
--- a/src/app/utils/sanitize.ts
+++ b/src/app/utils/sanitize.ts
@@ -155,6 +155,11 @@ export const sanitizeCustomHtml = (customHtml: string): string =>
     allowProtocolRelative: false,
     allowedClasses: {
       code: ['language-*'],
+      // `pre` permits `class` (for `
` wrappers); without
+      // an allowedClasses entry, sanitize-html lets a remote sender put ARBITRARY
+      // class names on 
, activating site CSS (N100). Restrict to the same
+      // language-* whitelist as .
+      pre: ['language-*'],
     },
     allowedStyles: {
       '*': {