Created
December 7, 2023 19:55
-
-
Save raysuelzer/48c383e992c62066eef35164b80e9c24 to your computer and use it in GitHub Desktop.
Angular Style Safe Sanitizer
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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; | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
And then a pipe