Switch to DOMPurify for HTML email sanitization

This commit is contained in:
Ben Gotow 2024-01-08 10:45:15 -06:00
parent 6ab1b642b7
commit 8b4f59ba49
5 changed files with 257 additions and 529 deletions

11
app/package-lock.json generated
View file

@ -21,6 +21,7 @@
"collapse-whitespace": "^1.1.6",
"debug": "github:emorikawa/debug#nylas",
"deep-extend": "0.6.0",
"dompurify": "^3.0.8",
"emoji-data": "^0.2.0",
"enzyme": "^3.8.0",
"enzyme-adapter-react-16": "^1.9.0",
@ -1154,6 +1155,11 @@
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/dompurify": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.0.8.tgz",
"integrity": "sha512-b7uwreMYL2eZhrSCRC4ahLTeZcPZxSmYfmcQGXGkXiZSNW1X85v+SDM5KsWcpivIiUBH47Ji7NtyUdpLeF5JZQ=="
},
"node_modules/domutils": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz",
@ -5937,6 +5943,11 @@
"domelementtype": "^2.3.0"
}
},
"dompurify": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.0.8.tgz",
"integrity": "sha512-b7uwreMYL2eZhrSCRC4ahLTeZcPZxSmYfmcQGXGkXiZSNW1X85v+SDM5KsWcpivIiUBH47Ji7NtyUdpLeF5JZQ=="
},
"domutils": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz",

View file

@ -11,9 +11,9 @@
"license": "GPL-3.0",
"main": "./src/browser/main.js",
"dependencies": {
"app-module-path": "^2.2.0",
"@bengotow/slate-edit-list": "github:bengotow/slate-edit-list#b868e108",
"@electron/remote": "^2.0.9",
"app-module-path": "^2.2.0",
"better-sqlite3": "^8.0.1",
"cheerio": "^1.0.0-rc.6",
"chromium-net-errors": "1.0.3",
@ -23,6 +23,7 @@
"collapse-whitespace": "^1.1.6",
"debug": "github:emorikawa/debug#nylas",
"deep-extend": "0.6.0",
"dompurify": "^3.0.8",
"emoji-data": "^0.2.0",
"enzyme": "^3.8.0",
"enzyme-adapter-react-16": "^1.9.0",

View file

@ -50,7 +50,7 @@ class DraftFactory {
// Be sure to match over multiple lines with [\s\S]*
// Regex explanation here: https://regex101.com/r/vO6eN2/1
let transformed = (content || '').replace(cidRegexp, '');
transformed = await SanitizeTransformer.run(transformed, SanitizeTransformer.Preset.UnsafeOnly);
transformed = await SanitizeTransformer.run(transformed);
transformed = await InlineStyleTransformer.run(transformed);
return transformed;
}
@ -259,8 +259,8 @@ class DraftFactory {
</div>
`
: `\n\n---------- ${localized('Forwarded Message')} ---------\n\n${fields.join(
'\n'
)}\n\n${body}`,
'\n'
)}\n\n${body}`,
});
}

View file

@ -168,7 +168,7 @@ class MessageBodyProcessor {
// Sanitizing <script> tags, etc. isn't necessary because we use CORS rules
// to prevent their execution and sandbox content in the iFrame, but we still
// want to remove contenteditable attributes and other strange things.
body = await SanitizeTransformer.run(body, SanitizeTransformer.Preset.UnsafeOnly);
body = await SanitizeTransformer.run(body);
for (const extension of MessageStore.extensions()) {
if (!extension.formatMessageBody) {

View file

@ -1,534 +1,250 @@
/* eslint-disable no-prototype-builtins */
/*
The sanitizer implementation here is loosely based on
https://www.quaxio.com/html_white_listed_sanitizer/
*/
import DOMPurify from 'dompurify';
const asMap = arr => {
const obj = {};
arr.forEach(k => (obj[k] = true));
return obj;
};
const sanitizeURL = function(val, rules) {
if (!val) {
return null;
}
if (rules.except && rules.except.find(scheme => val.startsWith(scheme))) {
return null;
}
return val;
};
// https://stackoverflow.com/questions/2725156/complete-list-of-html-tag-attributes-which-have-a-url-value
const AttributesContainingLinks = [
'src',
'codebase',
const AllowedTags = [
'#text',
'a',
'abbr',
'address',
'area',
'article',
'aside',
'b',
'bdi',
'bdo',
'big',
'blockquote',
'body',
'br',
'button',
'canvas',
'caption',
'cite',
'background',
'longdesc',
'profile',
'usemap',
'center',
'code',
'col',
'colgroup',
'data',
'href',
'action',
'formaction',
'icon',
'manifest',
'poster',
'srcdoc',
'srcset',
'archive',
'classid',
'datalist',
'dd',
'del',
'details',
'dfn',
'dialog',
'div',
'dl',
'dt',
'em',
'fieldset',
'figcaption',
'figure',
'footer',
'font',
'form',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'header',
'hr',
'i',
'img',
'input',
'ins',
'kbd',
'keygen',
'label',
'legend',
'li',
'main',
'map',
'mark',
'menu',
'menuitem',
'meta',
'meter',
'nav',
'ol',
'optgroup',
'option',
'output',
'p',
'param',
'picture',
'pre',
'progress',
'q',
'rp',
'rt',
'ruby',
's',
'samp',
'section',
'select',
'small',
'source',
'span',
'strong',
'style',
'strike',
'sub',
'summary',
'sup',
'table',
'tbody',
'td',
'textarea',
'tfoot',
'th',
'thead',
'time',
'title',
'tr',
'track',
'u',
'ul',
'var',
'wbr',
'html',
];
const NodesWithNonTextContent = asMap(['script', 'style', 'iframe', 'object', 'meta']);
const Preset = {
PasteFragment: {
fragment: true,
allowedTags: asMap([
'p',
'b',
'i',
'em',
'u',
's',
'strong',
'center',
'a',
'br',
'img',
'ul',
'ol',
'li',
'strike',
'table',
'tr',
'td',
'th',
'col',
'colgroup',
'div',
'html',
'font',
]),
allowedSchemes: {
default: { except: ['file:'] },
},
allowedAttributes: {
default: asMap([
'abbr',
'accept',
'acceptcharset',
'accesskey',
'action',
'align',
'alt',
'async',
'autocomplete',
'axis',
'border',
'background',
'bgcolor',
'cellpadding',
'cellspacing',
'char',
'charoff',
'charset',
'checked',
'class',
'classid',
'classname',
'color',
'colspan',
'cols',
'content',
'contextmenu',
'controls',
'coords',
'data-overlay-id',
'data-component-props',
'data-component-key',
'data',
'datetime',
'defer',
'dir',
'disabled',
'download',
'draggable',
'enctype',
'face',
'form',
'formaction',
'formenctype',
'formmethod',
'formnovalidate',
'formtarget',
'frame',
'frameborder',
'headers',
'height',
'hidden',
'high',
'href',
'hreflang',
'htmlfor',
'httpequiv',
'icon',
'id',
'label',
'lang',
'list',
'loop',
'low',
'manifest',
'marginheight',
'marginwidth',
'max',
'maxlength',
'media',
'mediagroup',
'method',
'min',
'multiple',
'muted',
'name',
'novalidate',
'nowrap',
'open',
'optimum',
'pattern',
'placeholder',
'poster',
'preload',
'radiogroup',
'readonly',
'rel',
'required',
'role',
'rowspan',
'rows',
'rules',
'sandbox',
'scope',
'scoped',
'scrolling',
'seamless',
'selected',
'shape',
'size',
'sizes',
'sortable',
'sorted',
'span',
'spellcheck',
'src',
'srcdoc',
'srcset',
'start',
'step',
'style',
'summary',
'tabindex',
'target',
'title',
'translate',
'type',
'usemap',
'valign',
'value',
'width',
'wmode',
]),
},
},
UnsafeOnly: {
allowedTags: asMap([
'a',
'abbr',
'address',
'area',
'article',
'aside',
'b',
'bdi',
'bdo',
'big',
'blockquote',
'body',
'br',
'button',
'canvas',
'caption',
'cite',
'center',
'code',
'col',
'colgroup',
'data',
'datalist',
'dd',
'del',
'details',
'dfn',
'dialog',
'div',
'dl',
'dt',
'em',
'fieldset',
'figcaption',
'figure',
'footer',
'font',
'form',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'header',
'hr',
'i',
'img',
'input',
'ins',
'kbd',
'keygen',
'label',
'legend',
'li',
'main',
'map',
'mark',
'menu',
'menuitem',
'meta',
'meter',
'nav',
'ol',
'optgroup',
'option',
'output',
'p',
'param',
'picture',
'pre',
'progress',
'q',
'rp',
'rt',
'ruby',
's',
'samp',
'section',
'select',
'small',
'source',
'span',
'strong',
'style',
'strike',
'sub',
'summary',
'sup',
'table',
'tbody',
'td',
'textarea',
'tfoot',
'th',
'thead',
'time',
'title',
'tr',
'track',
'u',
'ul',
'var',
'wbr',
'html',
]),
allowedAttributes: {
default: asMap([
'abbr',
'accept',
'acceptcharset',
'accesskey',
'action',
'align',
'alt',
'async',
'autocomplete',
'axis',
'border',
'background',
'bgcolor',
'cellpadding',
'cellspacing',
'char',
'charoff',
'charset',
'checked',
'classid',
'class',
'classname',
'clear',
'colspan',
'cols',
'color',
'content',
'contextmenu',
'controls',
'compact',
'coords',
'data',
'datetime',
'defer',
'dir',
'disabled',
'download',
'draggable',
'enctype',
'face',
'form',
'formaction',
'formenctype',
'formmethod',
'formnovalidate',
'formtarget',
'frame',
'frameborder',
'headers',
'height',
'hidden',
'high',
'href',
'hreflang',
'htmlfor',
'httpequiv',
'hspace',
'icon',
'id',
'label',
'lang',
'list',
'loop',
'low',
'manifest',
'marginheight',
'marginwidth',
'max',
'maxlength',
'media',
'mediagroup',
'method',
'min',
'multiple',
'muted',
'name',
'novalidate',
'nowrap',
'noshade',
'open',
'optimum',
'pattern',
'placeholder',
'poster',
'preload',
'radiogroup',
'readonly',
'rel',
'required',
'role',
'rowspan',
'rows',
'rules',
'sandbox',
'scope',
'scoped',
'scrolling',
'seamless',
'selected',
'shape',
'size',
'sizes',
'start',
'sortable',
'sorted',
'span',
'spellcheck',
'src',
'srcdoc',
'srcset',
'start',
'step',
'style',
'summary',
'tabindex',
'target',
'title',
'translate',
'type',
'usemap',
'valign',
'value',
'vspace',
'width',
'wmode',
]),
},
allowedSchemes: {
default: { except: ['file:'] },
},
},
};
const AllowedAttributes = [
'abbr',
'accept',
'acceptcharset',
'accesskey',
'action',
'align',
'alt',
'async',
'autocomplete',
'axis',
'border',
'background',
'bgcolor',
'cellpadding',
'cellspacing',
'char',
'charoff',
'charset',
'checked',
'classid',
'class',
'classname',
'clear',
'colspan',
'cols',
'color',
'content',
'contextmenu',
'controls',
'compact',
'coords',
'data',
'datetime',
'defer',
'dir',
'disabled',
'download',
'draggable',
'enctype',
'face',
'form',
'formaction',
'formenctype',
'formmethod',
'formnovalidate',
'formtarget',
'frame',
'frameborder',
'headers',
'height',
'hidden',
'high',
'href',
'hreflang',
'htmlfor',
'httpequiv',
'hspace',
'icon',
'id',
'label',
'lang',
'list',
'loop',
'low',
'manifest',
'marginheight',
'marginwidth',
'max',
'maxlength',
'media',
'mediagroup',
'method',
'min',
'multiple',
'muted',
'name',
'novalidate',
'nowrap',
'noshade',
'open',
'optimum',
'pattern',
'placeholder',
'poster',
'preload',
'radiogroup',
'readonly',
'rel',
'required',
'role',
'rowspan',
'rows',
'rules',
'sandbox',
'scope',
'scoped',
'scrolling',
'seamless',
'selected',
'shape',
'size',
'sizes',
'start',
'sortable',
'sorted',
'span',
'spellcheck',
'src',
'srcdoc',
'srcset',
'start',
'step',
'style',
'summary',
'tabindex',
'target',
'title',
'translate',
'type',
'usemap',
'valign',
'value',
'vspace',
'width',
'wmode',
];
class SanitizeTransformer {
Preset = Preset;
sanitizeNode(node: Element, settings) {
const nodeName = node.nodeName.toLowerCase();
if (nodeName === '#text') {
return true; // text nodes are always safe, don't need to clone them
}
if (nodeName === '#comment') {
return false; // always strip comments
}
if (!settings.allowedTags.hasOwnProperty(nodeName)) {
// this node isn't allowed - what should we do with it?
// Nodes with non-text contents: completely remove them
if (NodesWithNonTextContent.hasOwnProperty(nodeName)) {
return false;
}
// Nodes with text contents / no contents: replace with a `span` with the same children.
// This allows us to ignore things like tables / table cells and still get their contents.
const replacementNode = document.createElement('span');
for (const child of Array.from(node.childNodes)) {
replacementNode.appendChild(child);
}
node.parentNode.replaceChild(replacementNode, node);
node = replacementNode;
}
// identify the allowed attributes based on our settings
let allowedForNodeName = settings.allowedAttributes.default;
if (settings.allowedAttributes.hasOwnProperty(nodeName)) {
allowedForNodeName = settings.allowedAttributes[nodeName];
}
// remove and sanitize attributes using the whitelist / URL scheme rules
for (let i = node.attributes.length - 1; i >= 0; i--) {
const attr = node.attributes.item(i).name;
if (!allowedForNodeName.hasOwnProperty(attr)) {
node.removeAttribute(attr);
continue;
}
if (AttributesContainingLinks.includes(attr)) {
let rules = settings.allowedSchemes.default;
if (settings.allowedSchemes.hasOwnProperty(nodeName)) {
rules = settings.allowedSchemes[nodeName];
}
const sanitizedValue = sanitizeURL(node.getAttribute(attr), rules);
if (sanitizedValue !== null) {
node.setAttribute(attr, sanitizedValue);
} else {
node.removeAttribute(attr);
}
}
}
// recursively sanitize child nodes
for (let i = node.childNodes.length - 1; i >= 0; i--) {
if (!this.sanitizeNode(node.childNodes[i] as Element, settings)) {
node.childNodes[i].remove();
}
}
return true;
}
async run(body, settings) {
if (settings.fragment) {
const doc = document.implementation.createHTMLDocument();
const div = doc.createElement('div');
div.innerHTML = body;
this.sanitizeNode(div, settings);
return div.innerHTML;
} else {
const parser = new DOMParser();
const doc = parser.parseFromString(body, 'text/html');
this.sanitizeNode(doc.documentElement, settings);
return doc.documentElement.innerHTML;
}
async run(bodyHTML: string) {
return DOMPurify.sanitize(bodyHTML, {
ALLOWED_TAGS: AllowedTags,
ALLOWED_ATTR: AllowedAttributes,
ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|xxx):|[^a-z]|[a-z+.-]+(?:[^a-z+.\-:]|$))/i,
KEEP_CONTENT: true,
});
}
}