fix(security,notifications): pre class allowlist, notification privacy + icon, sync-script safety (N100/N106/N109/N119)
- N100: restrict <pre> classes to language-* in sanitize-html allowedClasses; previously `class` was allowed on <pre> 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 <noreply@anthropic.com>
This commit is contained in:
@@ -56,7 +56,8 @@ async function headCheck(slug) {
|
|||||||
const res = await fetch(`${CDN}/${slug}.png`, { method: 'HEAD' });
|
const res = await fetch(`${CDN}/${slug}.png`, { method: 'HEAD' });
|
||||||
return { slug, ok: res.ok, status: res.status };
|
return { slug, ok: res.ok, status: res.status };
|
||||||
} catch {
|
} 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);
|
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);
|
const found = results.filter((r) => r.ok);
|
||||||
|
|
||||||
if (missing.length === 0) {
|
if (missing.length === 0) {
|
||||||
|
|||||||
@@ -242,6 +242,7 @@ function MessageNotifications() {
|
|||||||
roomId,
|
roomId,
|
||||||
eventId,
|
eventId,
|
||||||
body,
|
body,
|
||||||
|
encrypted,
|
||||||
}: {
|
}: {
|
||||||
roomName: string;
|
roomName: string;
|
||||||
roomAvatar?: string;
|
roomAvatar?: string;
|
||||||
@@ -249,6 +250,7 @@ function MessageNotifications() {
|
|||||||
roomId: string;
|
roomId: string;
|
||||||
eventId: string;
|
eventId: string;
|
||||||
body?: string;
|
body?: string;
|
||||||
|
encrypted?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const roomPath = mDirects.has(roomId)
|
const roomPath = mDirects.has(roomId)
|
||||||
? getDirectRoomPath(roomId, eventId)
|
? getDirectRoomPath(roomId, eventId)
|
||||||
@@ -267,10 +269,17 @@ function MessageNotifications() {
|
|||||||
return;
|
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, {
|
const noti = new window.Notification(roomName, {
|
||||||
icon: roomAvatar,
|
icon: LogoSVG,
|
||||||
badge: roomAvatar,
|
badge: LogoSVG,
|
||||||
body: body ? `${username}: ${body}`.slice(0, 120) : username,
|
body: !encrypted && body ? `${username}: ${body}`.slice(0, 120) : username,
|
||||||
silent: true,
|
silent: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -341,6 +350,7 @@ function MessageNotifications() {
|
|||||||
roomId: room.roomId,
|
roomId: room.roomId,
|
||||||
eventId,
|
eventId,
|
||||||
body: (mEvent.getContent().body as string | undefined) ?? '',
|
body: (mEvent.getContent().body as string | undefined) ?? '',
|
||||||
|
encrypted: room.hasEncryptionStateEvent(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -155,6 +155,11 @@ export const sanitizeCustomHtml = (customHtml: string): string =>
|
|||||||
allowProtocolRelative: false,
|
allowProtocolRelative: false,
|
||||||
allowedClasses: {
|
allowedClasses: {
|
||||||
code: ['language-*'],
|
code: ['language-*'],
|
||||||
|
// `pre` permits `class` (for `<pre class="language-*">` wrappers); without
|
||||||
|
// an allowedClasses entry, sanitize-html lets a remote sender put ARBITRARY
|
||||||
|
// class names on <pre>, activating site CSS (N100). Restrict to the same
|
||||||
|
// language-* whitelist as <code>.
|
||||||
|
pre: ['language-*'],
|
||||||
},
|
},
|
||||||
allowedStyles: {
|
allowedStyles: {
|
||||||
'*': {
|
'*': {
|
||||||
|
|||||||
Reference in New Issue
Block a user