Files
cinny/src/app/utils/sanitize.ts
T
jared 16dddcb9f0 fix: comprehensive P0 quality pass — audit findings resolved
- ReportRoomModal: use mx.reportRoom() SDK method, fix undefined CSS vars
  (--mx-surface/border → folds color tokens), add role/aria-modal/aria-labelledby,
  accessible select/input labels, per-error-code messages, auto-close on success
- About.tsx: clickable matrix_id + email_address links (Text as="a"), AbortController
  cleanup, runtime JSON type guard, loading state, role display for all role values,
  remove classList theming hack, use mx.getHomeserverUrl()
- RoomViewHeader: useLocalRoomName for header title, useReportRoomSupported gate,
  hide Invite/Settings/Report for server notice rooms, isCreator guard on Report,
  FocusTrap returnFocusOnDeactivate on topic overlay, Server Notice chip tooltip
- RoomInput: replace raw <div> with folds <Box> for server notice read-only message
- EditHistoryModal: isRawEditEvent type guard, handle next_batch truncation,
  getVersionBody handles formatted_body (strips HTML for text display),
  role/aria-modal/aria-labelledby accessibility, guard for undefined eventId,
  use config.space tokens (remove var(--mx-spacing-*) strings)
- RoomNavItem: remove duplicate getExistingContent (use exported getLocalRoomNamesContent),
  maxLength={255} on rename input, fix FocusTrap nesting (renameDialog state moved to
  RoomNavItem_, RenameRoomDialog rendered outside menu, menu closes before dialog opens),
  pencil icon opacity via config.opacity.P300
- useRoomMeta: export getLocalRoomNamesContent for reuse
- RoomIntro: useLocalRoomName, formatted topic viewer with Overlay/FocusTrap/RoomTopicViewer
- CallRoomName: useLocalRoomName for consistent rename display in call overlay
- General.tsx: fix #980000/#FF6B00 hardcoded hex → color tokens/CSS vars, URL Preview
  capitalization, improved encrypted preview warning text + Warning chip, add
  description to plain urlPreview setting
- sanitize.ts: fix hex color regex to support 3/4/6/8 digit hex (CSS4 #RGBA, #RRGGBBAA)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 21:30:27 -04:00

185 lines
4.0 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-*'],
},
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);
};