Files
cinny/src/app/utils/sanitize.ts
T
jared 51d468fbcc 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>
2026-06-28 12:35:33 -04:00

190 lines
4.3 KiB
TypeScript

import sanitizeHtml, { Transformer } from 'sanitize-html';
const MAX_TAG_NESTING = 100;
const permittedHtmlTags = [
'font',
'del',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'blockquote',
'p',
'a',
'ul',
'ol',
'sup',
'sub',
'li',
'b',
'i',
'u',
'strong',
'em',
'strike',
's',
'code',
'hr',
'br',
'div',
'table',
'thead',
'tbody',
'tr',
'th',
'td',
'caption',
'pre',
'span',
'img',
'details',
'summary',
];
const urlSchemes = ['https', 'http', 'ftp', 'mailto', 'magnet'];
const permittedTagToAttributes = {
font: ['style', 'data-mx-bg-color', 'data-mx-color', 'color'],
span: [
'style',
'data-mx-bg-color',
'data-mx-color',
'data-mx-spoiler',
'data-mx-maths',
'data-mx-pill',
'data-mx-ping',
'data-md',
],
div: ['data-mx-maths'],
blockquote: ['data-md'],
h1: ['data-md'],
h2: ['data-md'],
h3: ['data-md'],
h4: ['data-md'],
h5: ['data-md'],
h6: ['data-md'],
pre: ['data-md', 'class'],
ol: ['start', 'type', 'data-md'],
ul: ['data-md'],
a: ['name', 'target', 'href', 'rel', 'data-md'],
img: ['width', 'height', 'alt', 'title', 'src', 'data-mx-emoticon'],
code: ['class', 'data-md', 'data-label'],
strong: ['data-md'],
i: ['data-md'],
em: ['data-md'],
u: ['data-md'],
s: ['data-md'],
del: ['data-md'],
};
const transformFontTag: Transformer = (tagName, attribs) => ({
tagName,
attribs: {
...attribs,
style: `${
attribs['data-mx-bg-color'] && /^#[0-9a-fA-F]{3,8}$/.test(attribs['data-mx-bg-color'])
? `background-color: ${attribs['data-mx-bg-color']};`
: ''
} ${
attribs['data-mx-color'] && /^#[0-9a-fA-F]{3,8}$/.test(attribs['data-mx-color'])
? `color: ${attribs['data-mx-color']}`
: ''
}`.trim(),
},
});
const transformSpanTag: Transformer = (tagName, attribs) => ({
tagName,
attribs: {
...attribs,
style: `${
attribs['data-mx-bg-color'] && /^#[0-9a-fA-F]{3,8}$/.test(attribs['data-mx-bg-color'])
? `background-color: ${attribs['data-mx-bg-color']};`
: ''
} ${
attribs['data-mx-color'] && /^#[0-9a-fA-F]{3,8}$/.test(attribs['data-mx-color'])
? `color: ${attribs['data-mx-color']}`
: ''
}`.trim(),
},
});
const transformATag: Transformer = (tagName, attribs) => ({
tagName,
attribs: {
...attribs,
rel: 'noreferrer noopener',
target: '_blank',
},
});
const transformImgTag: Transformer = (tagName, attribs) => {
const { src } = attribs;
if (typeof src === 'string' && src.startsWith('mxc://') === false) {
return {
tagName: 'a',
attribs: {
href: src,
rel: 'noreferrer noopener',
target: '_blank',
},
text: attribs.alt || src,
};
}
return {
tagName,
attribs: {
...attribs,
},
};
};
export const sanitizeCustomHtml = (customHtml: string): string =>
sanitizeHtml(customHtml, {
allowedTags: permittedHtmlTags,
allowedAttributes: permittedTagToAttributes,
disallowedTagsMode: 'discard',
allowedSchemes: urlSchemes,
allowedSchemesByTag: {
a: urlSchemes,
},
allowedSchemesAppliedToAttributes: ['href'],
allowProtocolRelative: false,
allowedClasses: {
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: {
'*': {
color: [/^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/],
'background-color': [/^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/],
},
},
transformTags: {
font: transformFontTag,
span: transformSpanTag,
a: transformATag,
img: transformImgTag,
},
nonTextTags: ['style', 'script', 'textarea', 'option', 'noscript', 'mx-reply'],
nestingLimit: MAX_TAG_NESTING,
});
export const sanitizeText = (body: string) => {
const tagsToReplace: Record<string, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
};
return body.replace(/[&<>'"]/g, (tag) => tagsToReplace[tag] || tag);
};