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 `
` 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: {
      '*': {
        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 = {
    '&': '&',
    '<': '<',
    '>': '>',
    '"': '"',
    "'": ''',
  };
  return body.replace(/[&<>'"]/g, (tag) => tagsToReplace[tag] || tag);
};