Skip to content

Instantly share code, notes, and snippets.

@raysuelzer
Created December 7, 2023 19:55
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save raysuelzer/48c383e992c62066eef35164b80e9c24 to your computer and use it in GitHub Desktop.
Save raysuelzer/48c383e992c62066eef35164b80e9c24 to your computer and use it in GitHub Desktop.
Angular Style Safe Sanitizer
// Original JS source: https://github.com/jitbit/HtmlSanitizer/blob/master/HtmlSanitizer.js
export class HtmlSanitizer {
private _tagWhitelist = {
A: true, ABBR: true, B: true, BLOCKQUOTE: true, BODY: true, BR: true, CENTER: true,
CODE: true, DD: true, DIV: true, DL: true, DT: true, EM: true, FONT: true,
H1: true, H2: true, H3: true, H4: true, H5: true, H6: true, HR: true, I: true,
IMG: true, LABEL: true, LI: true, OL: true, P: true, PRE: true,
SMALL: true, SOURCE: true, SPAN: true, STRONG: true, SUB: true, SUP: true, TABLE: true,
TBODY: true, TR: true, TD: true, TH: true, THEAD: true, UL: true, U: true, VIDEO: true
};
private _contentTagWhiteList = { FORM: true, 'GOOGLE-SHEETS-HTML-ORIGIN': true };
private _attributeWhitelist = {
align: true, color: true, controls: true, height: true,
href: true, id: true, src: true, style: true, target: true, title: true, type: true, width: true
};
private _cssWhitelist = {
'background-color': true, color: true, 'font-size': true,
'font-weight': true, 'text-align': true, 'text-decoration': true, width: true
};
private _schemaWhiteList = ['http:', 'https:', 'data:', 'm-files:', 'file:', 'ftp:', 'mailto:', 'pw:'];
private _uriAttributes = { href: true, action: true };
private _parser = new DOMParser();
public SanitizeHtml(input: string, extraSelector?: string): string {
input = input.trim();
if (input === '') { return ''; } //to save performance
//firefox "bogus node" workaround for wysiwyg's
if (input === '<br>') { return ''; }
//add "body" otherwise some tags are skipped, like <style>
if (input.indexOf('<body') === -1) {
input = '<body>' + input + '</body>';
}
const doc = this._parser.parseFromString(input, 'text/html');
//DOM clobbering check (damn you firefox)
if (doc.body.tagName !== 'BODY') { doc.body.remove(); }
if (typeof doc.createElement !== 'function') { (doc.createElement as any).remove(); }
const resultElement = this.makeSanitizedCopy(doc.body, doc, extraSelector);
return resultElement.innerHTML
.replace(/<br[^>]*>(\S)/g, '<br>\n$1')
.replace(/div><div/g, 'div>\n<div'); //replace is just for cleaner code
}
private makeSanitizedCopy(node: Node, doc: Document, extraSelector?: string): Element {
let newNode;
const isElementNode = node.nodeType === Node.ELEMENT_NODE;
const isTagNameAllowed = this._tagWhitelist[node.nodeName] || this._contentTagWhiteList[node.nodeName];
const matchesExtraSelector = extraSelector && (node as Element).matches(extraSelector);
if (node.nodeType === Node.TEXT_NODE) {
newNode = node.cloneNode(true);
} else if (isElementNode && (isTagNameAllowed || matchesExtraSelector)) {
const elNode = node as HTMLElement;
if (this._contentTagWhiteList[elNode.tagName]) {
newNode = doc.createElement('DIV'); //convert to DIV
} else {
newNode = doc.createElement(elNode.tagName);
}
// eslint-disable-next-line @typescript-eslint/prefer-for-of
for (let i = 0; i < elNode.attributes.length; i++) {
const attr = elNode.attributes[i];
if (this._attributeWhitelist[attr.name]) {
if (attr.name === 'style') {
// eslint-disable-next-line @typescript-eslint/prefer-for-of
for (let s = 0; s < elNode.style.length; s++) {
const styleName = elNode.style[s];
if (this._cssWhitelist[styleName]) { newNode.style.setProperty(styleName, elNode.style.getPropertyValue(styleName)); }
}
} else {
if (this._uriAttributes[attr.name]) { //if this is a "uri" attribute, that can have "javascript:" or something
if (attr.value.indexOf(':') > -1 && !this.startsWithAny(attr.value, this._schemaWhiteList)) { continue; }
}
newNode.setAttribute(attr.name, attr.value);
}
}
}
for (let i = 0; i < elNode.childNodes.length; i++) {
const subCopy = this.makeSanitizedCopy(node.childNodes[i], doc, extraSelector);
newNode.appendChild(subCopy, false);
}
//remove useless empty spans (lots of those when pasting from MS Outlook)
if ((newNode.tagName === 'SPAN' || newNode.tagName === 'B' || newNode.tagName === 'I' || newNode.tagName === 'U')
&& newNode.innerHTML.trim() === '') {
return doc.createDocumentFragment() as any;
}
} else {
newNode = doc.createDocumentFragment();
}
return newNode;
};
private startsWithAny(str, substrings) {
// eslint-disable-next-line @typescript-eslint/prefer-for-of
for (let i = 0; i < substrings.length; i++) {
// eslint-disable-next-line eqeqeq
if (str.indexOf(substrings[i]) == 0) {
return true;
}
}
return false;
}
}
@raysuelzer
Copy link
Author

And then a pipe

import { Pipe, PipeTransform } from '@angular/core';
import { DomSanitizer } from '@angular/platform-browser';

import { HtmlSanitizer } from 'helpers'; // wherever you have the above file

/**
 * This pipe is used to sanitize HTML that is styled with CSS.
 * As angular will strip out the style tags in the default sanitizer.
 * This is a workaround for that.
 */
@Pipe({
  name: 'sanitizedStyledHtml'
})
export class SanitizedStyledHtmlPipe implements PipeTransform {
  santitzer = new HtmlSanitizer();

  constructor(private domSanitizer: DomSanitizer) {}


  transform(html) {
    const cleaned = this.santitzer.SanitizeHtml(html);
    return this.domSanitizer.bypassSecurityTrustHtml(cleaned);
  }
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment