CVE-2020-9038: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting') (CWE-79)
value: Joplin through 1.0.184 allows Arbitrary File Read via XSS.
CWE description: The product does not neutralize or incorrectly neutralizes user-controllable input before it is placed in output that is used as a web page that is served to other users.
CVSS v3 base score: 5.4
Published: 2020-02-17T16:15Z
Repository: https://github.com/laurent22/joplin
Language: JavaScript
Files changed: .eslintignore, .gitignore, CliClient/package-lock.json, CliClient/package.json, CliClient/tests/HtmlToHtml.js, CliClient/tests/MdToHtml.js, CliClient/tests/html_to_html/sanitize.dest.html, CliClient/tests/html_to_html/sanitize.src.html, CliClient/tests/md_to_html/sanitize.html, CliClient/tests/md_to_html/sanitize.md, Clipper/joplin-webclipper/content_scripts/index.js, ElectronClient/app/package-lock.json, ElectronClient/app/package.json, ElectronClient/build.sh, ReactNativeClient/lib/BaseApplication.js, ReactNativeClient/lib/joplin-renderer/HtmlToHtml.js, ReactNativeClient/lib/joplin-renderer/MarkupToHtml.js, ReactNativeClient/lib/joplin-renderer/MdToHtml.js, ReactNativeClient/lib/joplin-renderer/MdToHtml/rules/sanitize_html.ts, ReactNativeClient/lib/joplin-renderer/htmlUtils.js, ReactNativeClient/lib/joplin-renderer/package.json, ReactNativeClient/lib/shim-init-node.js, ReactNativeClient/package-lock.json, ReactNativeClient/package.json
Commit date: 2020-02-13 23:59:23 +0000
Commit message: All: Security: Fixed potential Arbitrary File Read via XSS

Diff: diff --git a/.eslintignore b/.eslintignore
index 69e2292f..b138ca65 100644
--- a/.eslintignore
+++ b/.eslintignore
@@ -53,5 +53,6 @@ ReactNativeClient/lib/joplin-renderer/assets/
 # Ignore files generated from TypeScript files
 ElectronClient/app/gui/ShareNoteDialog.js
 ReactNativeClient/lib/JoplinServerApi.js
 ReactNativeClient/PluginAssetsLoader.js
 ReactNativeClient/lib/joplin-renderer/MdToHtml/rules/mermaid.js
+ReactNativeClient/lib/joplin-renderer/MdToHtml/rules/sanitize_html.js
diff --git a/.gitignore b/.gitignore
index 67cc9393..5b744d39 100644
--- a/.gitignore
+++ b/.gitignore
@@ -49,5 +49,6 @@ Tools/commit_hook.txt
 # Ignore files generated from TypeScript files
 ElectronClient/app/gui/ShareNoteDialog.js
 ReactNativeClient/lib/JoplinServerApi.js
 ReactNativeClient/PluginAssetsLoader.js
 ReactNativeClient/lib/joplin-renderer/MdToHtml/rules/mermaid.js
+ReactNativeClient/lib/joplin-renderer/MdToHtml/rules/sanitize_html.js
diff --git a/CliClient/package-lock.json b/CliClient/package-lock.json
index 0e93828c..47474167 100644
--- a/CliClient/package-lock.json
+++ b/CliClient/package-lock.json
@@ -2665,10 +2665,15 @@
     "mdurl": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-1.0.1.tgz",
       "integrity": "sha1-/oWy7HWlkDfyrf7BAP1sYBdhFS4="
     },
+    "memory-cache": {
+      "version": "0.2.0",
+      "resolved": "https://registry.npmjs.org/memory-cache/-/memory-cache-0.2.0.tgz",
+      "integrity": "sha1-eJCwHVLADI68nVM+H46xfjA0hxo="
+    },
     "micromatch": {
       "version": "3.1.10",
       "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-3.1.10.tgz",
       "integrity": "sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg==",
       "requires": {
@@ -2918,10 +2923,19 @@
       "requires": {
         "encoding": "^0.1.11",
         "is-stream": "^1.0.1"
       }
     },
+    "node-html-parser": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/node-html-parser/-/node-html-parser-1.2.4.tgz",
+      "integrity": "sha512-qHwPdGyGr9pOZBoSgUOuNPG20QYZVN00lFcxKQgjPUODSxVH7obQeLVVawa3B4cfSNtLIeczSzoy/xYA8XG5WQ==",
+      "dev": true,
+      "requires": {
+        "he": "1.1.1"
+      }
+    },
     "node-persist": {
       "version": "2.1.0",
       "resolved": "https://registry.npmjs.org/node-persist/-/node-persist-2.1.0.tgz",
       "integrity": "sha1-5lK784haBNrWo1PXQXYXfIORRwc=",
       "requires": {
diff --git a/CliClient/package.json b/CliClient/package.json
index f3ecfa20..1d4da230 100644
--- a/CliClient/package.json
+++ b/CliClient/package.json
@@ -70,10 +70,11 @@
     "markdown-it-sub": "^1.0.0",
     "markdown-it-sup": "^1.0.0",
     "markdown-it-toc-done-right": "^4.1.0",
     "md5": "^2.2.1",
     "md5-file": "^4.0.0",
+    "memory-cache": "^0.2.0",
     "mime": "^2.0.3",
     "moment": "^2.24.0",
     "multiparty": "^4.2.1",
     "node-emoji": "^1.8.1",
     "node-fetch": "^1.7.1",
@@ -102,11 +103,12 @@
     "uslug": "^1.0.4",
     "uuid": "^3.0.1",
     "valid-url": "^1.0.9",
     "word-wrap": "^1.2.3",
     "xml2js": "^0.4.19",
-    "yargs-parser": "^7.0.0"
+    "yargs-parser": "^7.0.0",
+    "node-html-parser": "^1.2.4"
   },
   "devDependencies": {
     "jasmine": "^3.5.0"
   }
 }
diff --git a/CliClient/tests/HtmlToHtml.js b/CliClient/tests/HtmlToHtml.js
new file mode 100644
index 00000000..da614190
--- /dev/null
+++ b/CliClient/tests/HtmlToHtml.js
@@ -0,0 +1,79 @@
+/* eslint-disable no-unused-vars */
+
+require('app-module-path').addPath(__dirname);
+
+const os = require('os');
+const { time } = require('lib/time-utils.js');
+const { filename } = require('lib/path-utils.js');
+const { asyncTest, fileContentEqual, setupDatabase, setupDatabaseAndSynchronizer, db, synchronizer, fileApi, sleep, clearDatabase, switchClient, syncTargetId, objectsEqual, checkThrowAsync } = require('test-utils.js');
+const Folder = require('lib/models/Folder.js');
+const Note = require('lib/models/Note.js');
+const BaseModel = require('lib/BaseModel.js');
+const { shim } = require('lib/shim');
+const HtmlToHtml = require('lib/joplin-renderer/HtmlToHtml');
+const { enexXmlToMd } = require('lib/import-enex-md-gen.js');
+
+jasmine.DEFAULT_TIMEOUT_INTERVAL = 60 * 60 * 1000; // Can run for a while since everything is in the same test unit
+
+process.on('unhandledRejection', (reason, p) => {
+	console.log('Unhandled Rejection at: Promise', p, 'reason:', reason);
+});
+
+describe('HtmlToHtml', function() {
+
+	beforeEach(async (done) => {
+		await setupDatabaseAndSynchronizer(1);
+		await switchClient(1);
+		done();
+	});
+
+	it('should convert from Html to Html', asyncTest(async () => {
+		const basePath = `${__dirname}/html_to_html`;
+		const files = await shim.fsDriver().readDirStats(basePath);
+		const htmlToHtml = new HtmlToHtml();
+
+		for (let i = 0; i < files.length; i++) {
+			const htmlSourceFilename = files[i].path;
+			if (htmlSourceFilename.indexOf('.src.html') < 0) continue;
+
+			const htmlSourceFilePath = `${basePath}/${htmlSourceFilename}`;
+			const htmlDestPath = `${basePath}/${filename(filename(htmlSourceFilePath))}.dest.html`;
+
+			// if (htmlSourceFilename !== 'table_with_header.html') continue;
+
+			const htmlToHtmlOptions = {
+				bodyOnly: true,
+			};
+
+			const sourceHtml = await shim.fsDriver().readFile(htmlSourceFilePath);
+			let expectedHtml = await shim.fsDriver().readFile(htmlDestPath);
+
+			const result = await htmlToHtml.render(sourceHtml, null, htmlToHtmlOptions);
+			let actualHtml = result.html;
+
+			if (os.EOL === '\r\n') {
+				expectedHtml = expectedHtml.replace(/\r\n/g, '\n');
+				actualHtml = actualHtml.replace(/\r\n/g, '\n');
+			}
+
+			if (actualHtml !== expectedHtml) {
+				console.info('');
+				console.info(`Error converting file: ${htmlSourceFilename}`);
+				console.info('--------------------------------- Got:');
+				console.info(actualHtml);
+				console.info('--------------------------------- Raw:');
+				console.info(actualHtml.split('\n'));
+				console.info('--------------------------------- Expected:');
+				console.info(expectedHtml.split('\n'));
+				console.info('--------------------------------------------');
+				console.info('');
+
+				expect(false).toBe(true);
+				// return;
+			} else {
+				expect(true).toBe(true);
+			}
+		}
+	}));
+
+});
diff --git a/CliClient/tests/MdToHtml.js b/CliClient/tests/MdToHtml.js
new file mode 100644
index 00000000..e53ea491
--- /dev/null
+++ b/CliClient/tests/MdToHtml.js
@@ -0,0 +1,79 @@
+/* eslint-disable no-unused-vars */
+
+require('app-module-path').addPath(__dirname);
+
+const os = require('os');
+const { time } = require('lib/time-utils.js');
+const { filename } = require('lib/path-utils.js');
+const { asyncTest, fileContentEqual, setupDatabase, setupDatabaseAndSynchronizer, db, synchronizer, fileApi, sleep, clearDatabase, switchClient, syncTargetId, objectsEqual, checkThrowAsync } = require('test-utils.js');
+const Folder = require('lib/models/Folder.js');
+const Note = require('lib/models/Note.js');
+const BaseModel = require('lib/BaseModel.js');
+const { shim } = require('lib/shim');
+const MdToHtml = require('lib/joplin-renderer/MdToHtml');
+const { enexXmlToMd } = require('lib/import-enex-md-gen.js');
+
+jasmine.DEFAULT_TIMEOUT_INTERVAL = 60 * 60 * 1000; // Can run for a while since everything is in the same test unit
+
+process.on('unhandledRejection', (reason, p) => {
+	console.log('Unhandled Rejection at: Promise', p, 'reason:', reason);
+});
+
+describe('MdToHtml', function() {
+
+	beforeEach(async (done) => {
+		await setupDatabaseAndSynchronizer(1);
+		await switchClient(1);
+		done();
+	});
+
+	it('should convert from Markdown to Html', asyncTest(async () => {
+		const basePath = `${__dirname}/md_to_html`;
+		const files = await shim.fsDriver().readDirStats(basePath);
+		const mdToHtml = new MdToHtml();
+
+		for (let i = 0; i < files.length; i++) {
+			const mdFilename = files[i].path;
+			if (mdFilename.indexOf('.md') < 0) continue;
+
+			const mdFilePath = `${basePath}/${mdFilename}`;
+			const htmlPath = `${basePath}/${filename(mdFilePath)}.html`;
+
+			// if (mdFilename !== 'table_with_header.html') continue;
+
+			const mdToHtmlOptions = {
+				bodyOnly: true,
+			};
+
+			const markdown = await shim.fsDriver().readFile(mdFilePath);
+			let expectedHtml = await shim.fsDriver().readFile(htmlPath);
+
+			const result = await mdToHtml.render(markdown, null, mdToHtmlOptions);
+			let actualHtml = result.html;
+
+			if (os.EOL === '\r\n') {
+				expectedHtml = expectedHtml.replace(/\r\n/g, '\n');
+				actualHtml = actualHtml.replace(/\r\n/g, '\n');
+			}
+
+			if (actualHtml !== expectedHtml) {
+				console.info('');
+				console.info(`Error converting file: ${mdFilename}`);
+				console.info('--------------------------------- Got:');
+				console.info(actualHtml);
+				console.info('--------------------------------- Raw:');
+				console.info(actualHtml.split('\n'));
+				console.info('--------------------------------- Expected:');
+				console.info(expectedHtml.split('\n'));
+				console.info('--------------------------------------------');
+				console.info('');
+
+				expect(false).toBe(true);
+				// return;
+			} else {
+				expect(true).toBe(true);
+			}
+		}
+	}));
+
+});
diff --git a/CliClient/tests/html_to_html/sanitize.dest.html b/CliClient/tests/html_to_html/sanitize.dest.html
new file mode 100644
index 00000000..b0f10413
--- /dev/null
+++ b/CliClient/tests/html_to_html/sanitize.dest.html
@@ -0,0 +1,2 @@
+<img src onerror="" />
+<img src onerror="" />
\ No newline at end of file
diff --git a/CliClient/tests/html_to_html/sanitize.src.html b/CliClient/tests/html_to_html/sanitize.src.html
new file mode 100644
index 00000000..7c16698d
--- /dev/null
+++ b/CliClient/tests/html_to_html/sanitize.src.html
@@ -0,0 +1,3 @@
+<img src="" onerror="alert('ohno')"/>
+<img src=""
+   onerror="alert('ohno')"/>
\ No newline at end of file
diff --git a/CliClient/tests/md_to_html/sanitize.html b/CliClient/tests/md_to_html/sanitize.html
new file mode 100644
index 00000000..b0f10413
--- /dev/null
+++ b/CliClient/tests/md_to_html/sanitize.html
@@ -0,0 +1,2 @@
+<img src onerror="" />
+<img src onerror="" />
\ No newline at end of file
diff --git a/CliClient/tests/md_to_html/sanitize.md b/CliClient/tests/md_to_html/sanitize.md
new file mode 100644
index 00000000..7c16698d
--- /dev/null
+++ b/CliClient/tests/md_to_html/sanitize.md
@@ -0,0 +1,3 @@
+<img src="" onerror="alert('ohno')"/>
+<img src=""
+   onerror="alert('ohno')"/>
\ No newline at end of file
diff --git a/Clipper/joplin-webclipper/content_scripts/index.js b/Clipper/joplin-webclipper/content_scripts/index.js
index 772105a6..7852ca66 100644
--- a/Clipper/joplin-webclipper/content_scripts/index.js
+++ b/Clipper/joplin-webclipper/content_scripts/index.js
@@ -19,11 +19,11 @@
 	}
 
 	function absoluteUrl(url) {
 		if (!url) return url;
 		const protocol = url.toLowerCase().split(':')[0];
-		if (['http', 'https', 'file'].indexOf(protocol) >= 0) return url;
+		if (['http', 'https', 'file', 'data'].indexOf(protocol) >= 0) return url;
 
 		if (url.indexOf('//') === 0) {
 			return location.protocol + url;
 		} else if (url[0] === '/') {
 			return `${location.protocol}//${location.host}${url}`;
diff --git a/ElectronClient/app/package-lock.json b/ElectronClient/app/package-lock.json
index 77e1c284..cc833ecc 100644
--- a/ElectronClient/app/package-lock.json
+++ b/ElectronClient/app/package-lock.json
@@ -2231,14 +2231,72 @@
       "requires": {
         "htmlparser2": "^3.8.2",
         "minimist": "^1.1.1"
       },
       "dependencies": {
+        "domelementtype": {
+          "version": "1.3.1",
+          "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-1.3.1.tgz",
+          "integrity": "sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w=="
+        },
+        "domhandler": {
+          "version": "2.4.2",
+          "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-2.4.2.tgz",
+          "integrity": "sha512-JiK04h0Ht5u/80fdLMCEmV4zkNh2BcoMFBmZ/91WtYZ8qVXSKjiw7fXMgFPnHcSZgOo3XdinHvmnDUeMf5R4wA==",
+          "requires": {
+            "domelementtype": "1"
+          }
+        },
+        "domutils": {
+          "version": "1.7.0",
+          "resolved": "https://registry.npmjs.org/domutils/-/domutils-1.7.0.tgz",
+          "integrity": "sha512-Lgd2XcJ/NjEw+7tFvfKxOzCYKZsdct5lczQ2ZaQY8Djz7pfAD3Gbp8ySJWtreII/vDlMVmxwa6pHmdxIYgttDg==",
+          "requires": {
+            "dom-serializer": "0",
+            "domelementtype": "1"
+          }
+        },
+        "htmlparser2": {
+          "version": "3.10.1",
+          "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-3.10.1.tgz",
+          "integrity": "sha512-IgieNijUMbkDovyoKObU1DUhm1iwNYE/fuifEoEHfd1oZKZDaONBSkal7Y01shxsM49R4XaMdGez3WnF9UfiCQ==",
+          "requires": {
+            "domelementtype": "^1.3.1",
+            "domhandler": "^2.3.0",
+            "domutils": "^1.5.1",
+            "entities": "^1.1.1",
+            "inherits": "^2.0.1",
+            "readable-stream": "^3.1.1"
+          }
+        },
         "minimist": {
           "version": "1.2.0",
           "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz",
           "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ="
+        },
+        "readable-stream": {
+          "version": "3.6.0",
+          "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz",
+          "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==",
+          "requires": {
+            "inherits": "^2.0.3",
+            "string_decoder": "^1.1.1",
+            "util-deprecate": "^1.0.1"
+          }
+        },
+        "safe-buffer": {
+          "version": "5.2.0",
+          "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.0.tgz",
+          "integrity": "sha512-fZEwUGbVl7kouZs1jCdMLdt95hdIv0ZeHg6L7qPeciMZhZ+/gdesW4wgTARkrFWEpspjEATAzUGPG8N2jJiwbg=="
+        },
+        "string_decoder": {
+          "version": "1.3.0",
+          "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
+          "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
+          "requires": {
+            "safe-buffer": "~5.2.0"
+          }
         }
       }
     },
     "cli-boxes": {
       "version": "2.2.0",
@@ -3156,58 +3214,54 @@
       "requires": {
         "@babel/runtime": "^7.1.2"
       }
     },
     "dom-serializer": {
-      "version": "0.2.1",
-      "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-0.2.1.tgz",
-      "integrity": "sha512-sK3ujri04WyjwQXVoK4PU3y8ula1stq10GJZpqHIUgoGZdsGzAGu65BnU3d08aTVSvO7mGPZUc0wTEDL+qGE0Q==",
+      "version": "0.2.2",
+      "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-0.2.2.tgz",
+      "integrity": "sha512-2/xPb3ORsQ42nHYiSunXkDjPLBaEj/xTwUO4B7XCZQTRk7EBtTOPaygh10YAAh2OI1Qrp6NWfpAhzswj0ydt9g==",
       "requires": {
         "domelementtype": "^2.0.1",
         "entities": "^2.0.0"
       },
       "dependencies": {
-        "domelementtype": {
-          "version": "2.0.1",
-          "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.0.1.tgz",
-          "integrity": "sha512-5HOHUDsYZWV8FGWN0Njbr/Rn7f/eWSQi1v7+HsUVwXgn8nWWlL64zKDkS0n8ZmQ3mlWOMuXOnR+7Nx/5tMO5AQ=="
-        },
         "entities": {
           "version": "2.0.0",
           "resolved": "https://registry.npmjs.org/entities/-/entities-2.0.0.tgz",
           "integrity": "sha512-D9f7V0JSRwIxlRI2mjMqufDrRDnx8p+eEOz7aUM9SuvF8gsBzra0/6tbjl1m8eQHrZlYj6PxqE00hZ1SAIKPLw=="
         }
       }
     },
     "domelementtype": {
-      "version": "1.3.1",
-      "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-1.3.1.tgz",
-      "integrity": "sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w=="
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.0.1.tgz",
+      "integrity": "sha512-5HOHUDsYZWV8FGWN0Njbr/Rn7f/eWSQi1v7+HsUVwXgn8nWWlL64zKDkS0n8ZmQ3mlWOMuXOnR+7Nx/5tMO5AQ=="
     },
     "domexception": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/domexception/-/domexception-1.0.1.tgz",
       "integrity": "sha512-raigMkn7CJNNo6Ihro1fzG7wr3fHuYVytzquZKX5n0yizGsTcYgzdIUwj1X9pK0VvjeihV+XiclP+DjwbsSKug==",
       "requires": {
         "webidl-conversions": "^4.0.2"
       }
     },
     "domhandler": {
-      "version": "2.4.2",
-      "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-2.4.2.tgz",
-      "integrity": "sha512-JiK04h0Ht5u/80fdLMCEmV4zkNh2BcoMFBmZ/91WtYZ8qVXSKjiw7fXMgFPnHcSZgOo3XdinHvmnDUeMf5R4wA==",
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-3.0.0.tgz",
+      "integrity": "sha512-eKLdI5v9m67kbXQbJSNn1zjh0SDzvzWVWtX+qEI3eMjZw8daH9k8rlj1FZY9memPwjiskQFbe7vHVVJIAqoEhw==",
       "requires": {
-        "domelementtype": "1"
+        "domelementtype": "^2.0.1"
       }
     },
     "domutils": {
-      "version": "1.7.0",
-      "resolved": "https://registry.npmjs.org/domutils/-/domutils-1.7.0.tgz",
-      "integrity": "sha512-Lgd2XcJ/NjEw+7tFvfKxOzCYKZsdct5lczQ2ZaQY8Djz7pfAD3Gbp8ySJWtreII/vDlMVmxwa6pHmdxIYgttDg==",
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.0.0.tgz",
+      "integrity": "sha512-n5SelJ1axbO636c2yUtOGia/IcJtVtlhQbFiVDBZHKV5ReJO1ViX7sFEemtuyoAnBxk5meNSYgA8V4s0271efg==",
       "requires": {
-        "dom-serializer": "0",
-        "domelementtype": "1"
+        "dom-serializer": "^0.2.1",
+        "domelementtype": "^2.0.1",
+        "domhandler": "^3.0.0"
       }
     },
     "dot-prop": {
       "version": "5.2.0",
       "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.2.0.tgz",
@@ -4638,44 +4692,24 @@
           "integrity": "sha512-7j2y+40w61zy6YC2iRNpUe/NwhNyoXrYpHMrSunaMG64nRnaf96zO/KMQR4OyN/UnE5KLyEBnKHd4aG3rskjpQ=="
         }
       }
     },
     "htmlparser2": {
-      "version": "3.10.1",
-      "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-3.10.1.tgz",
-      "integrity": "sha512-IgieNijUMbkDovyoKObU1DUhm1iwNYE/fuifEoEHfd1oZKZDaONBSkal7Y01shxsM49R4XaMdGez3WnF9UfiCQ==",
-      "requires": {
-        "domelementtype": "^1.3.1",
-        "domhandler": "^2.3.0",
-        "domutils": "^1.5.1",
-        "entities": "^1.1.1",
-        "inherits": "^2.0.1",
-        "readable-stream": "^3.1.1"
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-4.0.0.tgz",
+      "integrity": "sha512-cChwXn5Vam57fyXajDtPXL1wTYc8JtLbr2TN76FYu05itVVVealxLowe2B3IEznJG4p9HAYn/0tJaRlGuEglFQ==",
+      "requires": {
+        "domelementtype": "^2.0.1",
+        "domhandler": "^3.0.0",
+        "domutils": "^2.0.0",
+        "entities": "^2.0.0"
       },
       "dependencies": {
-        "readable-stream": {
-          "version": "3.4.0",
-          "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.4.0.tgz",
-          "integrity": "sha512-jItXPLmrSR8jmTRmRWJXCnGJsfy85mB3Wd/uINMXA65yrnFo0cPClFIUWzo2najVNSl+mx7/4W8ttlLWJe99pQ==",
-          "requires": {
-            "inherits": "^2.0.3",
-            "string_decoder": "^1.1.1",
-            "util-deprecate": "^1.0.1"
-          }
-        },
-        "safe-buffer": {
-          "version": "5.2.0",
-          "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.0.tgz",
-          "integrity": "sha512-fZEwUGbVl7kouZs1jCdMLdt95hdIv0ZeHg6L7qPeciMZhZ+/gdesW4wgTARkrFWEpspjEATAzUGPG8N2jJiwbg=="
-        },
-        "string_decoder": {
-          "version": "1.3.0",
-          "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
-          "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
-          "requires": {
-            "safe-buffer": "~5.2.0"
-          }
+        "entities": {
+          "version": "2.0.0",
+          "resolved": "https://registry.npmjs.org/entities/-/entities-2.0.0.tgz",
+          "integrity": "sha512-D9f7V0JSRwIxlRI2mjMqufDrRDnx8p+eEOz7aUM9SuvF8gsBzra0/6tbjl1m8eQHrZlYj6PxqE00hZ1SAIKPLw=="
         }
       }
     },
     "http-cache-semantics": {
       "version": "4.0.3",
@@ -5580,10 +5614,15 @@
     "memoize-one": {
       "version": "5.0.4",
       "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.0.4.tgz",
       "integrity": "sha512-P0z5IeAH6qHHGkJIXWw0xC2HNEgkx/9uWWBQw64FJj3/ol14VYdfVGWWr0fXfjhhv3TKVIqUq65os6O4GUNksA=="
     },
+    "memory-cache": {
+      "version": "0.2.0",
+      "resolved": "https://registry.npmjs.org/memory-cache/-/memory-cache-0.2.0.tgz",
+      "integrity": "sha1-eJCwHVLADI68nVM+H46xfjA0hxo="
+    },
     "mermaid": {
       "version": "8.4.6",
       "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-8.4.6.tgz",
       "integrity": "sha512-6YQBkXfvhfjKIzRhtqbCics3pJurGrJAYEeqgyRcDZeTHQ/WCB2Bh/4wdAOho1Uffe0jXB+HjmHT5kEUOxudJw==",
       "requires": {
@@ -6181,10 +6220,25 @@
           "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
           "dev": true
         }
       }
     },
+    "node-html-parser": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/node-html-parser/-/node-html-parser-1.2.4.tgz",
+      "integrity": "sha512-qHwPdGyGr9pOZBoSgUOuNPG20QYZVN00lFcxKQgjPUODSxVH7obQeLVVawa3B4cfSNtLIeczSzoy/xYA8XG5WQ==",
+      "requires": {
+        "he": "1.1.1"
+      },
+      "dependencies": {
+        "he": {
+          "version": "1.1.1",
+          "resolved": "https://registry.npmjs.org/he/-/he-1.1.1.tgz",
+          "integrity": "sha1-k0EP0hsAlzUVH4howvJx80J+I/0="
+        }
+      }
+    },
     "node-notifier": {
       "version": "6.0.0",
       "resolved": "https://registry.npmjs.org/node-notifier/-/node-notifier-6.0.0.tgz",
       "integrity": "sha512-SVfQ/wMw+DesunOm5cKqr6yDcvUTDl/yc97ybGHMrteNEY6oekXpNpS3lZwgLlwz0FLgHoiW28ZpmBHUDg37cw==",
       "requires": {
diff --git a/ElectronClient/app/package.json b/ElectronClient/app/package.json
index 323c3005..c2e1c6bf 100644
--- a/ElectronClient/app/package.json
+++ b/ElectronClient/app/package.json
@@ -129,15 +129,17 @@
     "markdown-it-sub": "^1.0.0",
     "markdown-it-sup": "^1.0.0",
     "markdown-it-toc-done-right": "^4.1.0",
     "md5": "^2.2.1",
     "md5-file": "^4.0.0",
+    "memory-cache": "^0.2.0",
     "mermaid": "^8.4.6",
     "moment": "^2.22.2",
     "multiparty": "^4.2.1",
     "mustache": "^3.0.1",
     "node-fetch": "^1.7.3",
+    "node-html-parser": "^1.2.4",
     "node-notifier": "^6.0.0",
     "promise": "^8.0.1",
     "query-string": "^5.1.1",
     "react": "^16.12.0",
     "react-ace": "^6.1.4",
diff --git a/ElectronClient/build.sh b/ElectronClient/build.sh
index 76193518..f39c9192 100755
--- a/ElectronClient/build.sh
+++ b/ElectronClient/build.sh
@@ -3,10 +3,10 @@
 ROOT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
 BUILD_DIR="$ROOT_DIR/app"
 
 rsync -a --delete "$ROOT_DIR/../ReactNativeClient/lib/" "$BUILD_DIR/lib/"
 
-cd "$ROOT_DIR/.."
-npm run tsc
+# cd "$ROOT_DIR/.."
+# npm run tsc
 
 cd "$BUILD_DIR"
 npm run compile
diff --git a/ReactNativeClient/lib/BaseApplication.js b/ReactNativeClient/lib/BaseApplication.js
index 1ae4a52f..32d40074 100644
--- a/ReactNativeClient/lib/BaseApplication.js
+++ b/ReactNativeClient/lib/BaseApplication.js
@@ -537,31 +537,10 @@ class BaseApplication {
 		if (process && process.env && process.env.PORTABLE_EXECUTABLE_DIR) return `${process.env.PORTABLE_EXECUTABLE_DIR}/JoplinProfile`;
 
 		return `${os.homedir()}/.config/${Setting.value('appName')}`;
 	}
 
-	async testing() {
-		const markdownUtils = require('lib/markdownUtils');
-		const ClipperServer = require('lib/ClipperServer');
-		const server = new ClipperServer();
-		const HtmlToMd = require('lib/HtmlToMd');
-		const service = new HtmlToMd();
-		const html = await shim.fsDriver().readFile('/mnt/d/test.html');
-		let markdown = service.parse(html, { baseUrl: 'https://duckduckgo.com/' });
-		console.info(markdown);
-		console.info('--------------------------------------------------');
-
-		const imageUrls = markdownUtils.extractImageUrls(markdown);
-		let result = await server.downloadImages_(imageUrls);
-		result = await server.createResourcesFromPaths_(result);
-		console.info(result);
-		markdown = server.replaceImageUrlsByResources_(markdown, result);
-		console.info('--------------------------------------------------');
-		console.info(markdown);
-		console.info('--------------------------------------------------');
-	}
-
 	async start(argv) {
 		let startFlags = await this.handleStartFlags_(argv);
 
 		argv = startFlags.argv;
 		let initArgs = startFlags.matched;
diff --git a/ReactNativeClient/lib/joplin-renderer/HtmlToHtml.js b/ReactNativeClient/lib/joplin-renderer/HtmlToHtml.js
index 0a929c4e..0afb76b1 100644
--- a/ReactNativeClient/lib/joplin-renderer/HtmlToHtml.js
+++ b/ReactNativeClient/lib/joplin-renderer/HtmlToHtml.js
@@ -1,35 +1,52 @@
 const htmlUtils = require('./htmlUtils');
 const utils = require('./utils');
 const noteStyle = require('./noteStyle');
+const memoryCache = require('memory-cache');
+const md5 = require('md5');
 
 class HtmlToHtml {
 	constructor(options) {
 		if (!options) options = {};
 		this.resourceBaseUrl_ = 'resourceBaseUrl' in options ? options.resourceBaseUrl : null;
 		this.ResourceModel_ = options.ResourceModel;
+		this.cache_ = new memoryCache.Cache();
 	}
 
-	render(markup, theme, options) {
-		const html = htmlUtils.processImageTags(markup, data => {
-			if (!data.src) return null;
-
-			const r = utils.imageReplacement(this.ResourceModel_, data.src, options.resources, this.resourceBaseUrl_);
-			if (!r) return null;
-
-			if (typeof r === 'string') {
-				return {
-					type: 'replaceElement',
-					html: r,
-				};
-			} else {
-				return {
-					type: 'setAttributes',
-					attrs: r,
-				};
-			}
-		});
+	async render(markup, theme, options) {
+		const cacheKey = md5(escape(markup));
+		let html = this.cache_.get(cacheKey);
+
+		if (!html) {
+			html = htmlUtils.sanitizeHtml(markup);
+
+			html = htmlUtils.processImageTags(html, data => {
+				if (!data.src) return null;
+
+				const r = utils.imageReplacement(this.ResourceModel_, data.src, options.resources, this.resourceBaseUrl_);
+				if (!r) return null;
+
+				if (typeof r === 'string') {
+					return {
+						type: 'replaceElement',
+						html: r,
+					};
+				} else {
+					return {
+						type: 'setAttributes',
+						attrs: r,
+					};
+				}
+			});
+		}
+
+		if (options.bodyOnly) return {
+			html: html,
+			pluginAssets: [],
+		};
+
+		this.cache_.put(cacheKey, html, 1000 * 60 * 10);
 
 		const cssStrings = noteStyle(theme, options);
 		const styleHtml = `<style>${cssStrings.join('\n')}</style>`;
 
 		return {
diff --git a/ReactNativeClient/lib/joplin-renderer/MarkupToHtml.js b/ReactNativeClient/lib/joplin-renderer/MarkupToHtml.js
index 9b1f68c0..8c186949 100644
--- a/ReactNativeClient/lib/joplin-renderer/MarkupToHtml.js
+++ b/ReactNativeClient/lib/joplin-renderer/MarkupToHtml.js
@@ -31,11 +31,11 @@ class MarkupToHtml {
 
 	injectedJavaScript() {
 		return '';
 	}
 
-	render(markupLanguage, markup, theme, options) {
+	async render(markupLanguage, markup, theme, options) {
 		return this.renderer(markupLanguage).render(markup, theme, options);
 	}
 }
 
 MarkupToHtml.MARKUP_LANGUAGE_MARKDOWN = 1;
diff --git a/ReactNativeClient/lib/joplin-renderer/MdToHtml.js b/ReactNativeClient/lib/joplin-renderer/MdToHtml.js
index 2f29dca3..c0980c43 100644
--- a/ReactNativeClient/lib/joplin-renderer/MdToHtml.js
+++ b/ReactNativeClient/lib/joplin-renderer/MdToHtml.js
@@ -1,19 +1,21 @@
 const MarkdownIt = require('markdown-it');
 const md5 = require('md5');
 const noteStyle = require('./noteStyle');
 const { fileExtension } = require('./pathUtils');
+const memoryCache = require('memory-cache');
 const rules = {
 	image: require('./MdToHtml/rules/image'),
 	checkbox: require('./MdToHtml/rules/checkbox'),
 	katex: require('./MdToHtml/rules/katex'),
 	link_open: require('./MdToHtml/rules/link_open'),
 	html_image: require('./MdToHtml/rules/html_image'),
 	highlight_keywords: require('./MdToHtml/rules/highlight_keywords'),
 	code_inline: require('./MdToHtml/rules/code_inline'),
 	fountain: require('./MdToHtml/rules/fountain'),
 	mermaid: require('./MdToHtml/rules/mermaid').default,
+	sanitize_html: require('./MdToHtml/rules/sanitize_html').default,
 };
 const setupLinkify = require('./MdToHtml/setupLinkify');
 const hljs = require('highlight.js');
 const uslug = require('uslug');
 const markdownItAnchor = require('markdown-it-anchor');
@@ -48,10 +50,11 @@ class MdToHtml {
 
 		this.lastCodeHighlightCacheKey_ = null;
 		this.cachedHighlightedCode_ = {};
 		this.ResourceModel_ = options.ResourceModel;
 		this.pluginOptions_ = options.pluginOptions ? options.pluginOptions : {};
+		this.contextCache_ = new memoryCache.Cache();
 	}
 
 	pluginOptions(name) {
 		let o = this.pluginOptions_[name] ? this.pluginOptions_[name] : {};
 		o = Object.assign({
@@ -104,10 +107,11 @@ class MdToHtml {
 		};
 	}
 
 	async render(body, style = null, options = null) {
 		if (!options) options = {};
+		if (!('bodyOnly' in options)) options.bodyOnly = false;
 		if (!options.postMessageSyntax) options.postMessageSyntax = 'postMessage';
 		if (!options.paddingBottom) options.paddingBottom = '0';
 		if (!options.highlightedKeywords) options.highlightedKeywords = [];
 		if (!options.codeTheme) options.codeTheme = 'atom-one-light.css';
 
@@ -127,10 +131,11 @@ class MdToHtml {
 		if (cachedOutput) return cachedOutput;
 
 		const context = {
 			css: {},
 			pluginAssets: {},
+			cache: this.contextCache_,
 		};
 
 		const ruleOptions = Object.assign({}, options, {
 			resourceBaseUrl: this.resourceBaseUrl_,
 			ResourceModel: this.ResourceModel_,
@@ -201,10 +206,11 @@ class MdToHtml {
 		markdownIt.use(rules.link_open(context, ruleOptions));
 		markdownIt.use(rules.html_image(context, ruleOptions));
 		if (this.pluginEnabled('katex')) markdownIt.use(rules.katex(context, ruleOptions));
 		if (this.pluginEnabled('fountain')) markdownIt.use(rules.fountain(context, ruleOptions));
 		if (this.pluginEnabled('mermaid')) markdownIt.use(rules.mermaid(context, ruleOptions));
+		markdownIt.use(rules.sanitize_html(context, ruleOptions));
 		markdownIt.use(rules.highlight_keywords(context, ruleOptions));
 		markdownIt.use(rules.code_inline(context, ruleOptions));
 		markdownIt.use(markdownItAnchor, { slugify: uslugify });
 
 		for (let key in plugins) {
diff --git a/ReactNativeClient/lib/joplin-renderer/MdToHtml/rules/sanitize_html.ts b/ReactNativeClient/lib/joplin-renderer/MdToHtml/rules/sanitize_html.ts
new file mode 100644
index 00000000..2ecb46a8
--- /dev/null
+++ b/ReactNativeClient/lib/joplin-renderer/MdToHtml/rules/sanitize_html.ts
@@ -0,0 +1,40 @@
+const md5 = require('md5');
+const htmlUtils = require('../../htmlUtils');
+
+// @ts-ignore: Keep the function signature as-is despite unusued arguments
+function installRule(markdownIt:any, mdOptions:any, ruleOptions:any, context:any) {
+	markdownIt.core.ruler.push('sanitize_html', (state:any) => {
+		const tokens = state.tokens;
+
+		const walkHtmlTokens = (tokens:any[]) => {
+			if (!tokens || !tokens.length) return;
+
+			for (const token of tokens) {
+				if (!['html_block', 'html_inline'].includes(token.type)) {
+					walkHtmlTokens(token.children);
+					continue;
+				}
+
+				const cacheKey = md5(escape(token.content));
+				let sanitizedContent = context.cache.get(cacheKey);
+
+				if (!sanitizedContent) {
+					sanitizedContent = htmlUtils.sanitizeHtml(token.content);
+				}
+
+				token.content = sanitizedContent;
+
+				context.cache.put(cacheKey, sanitizedContent, 1000 * 60 * 60);
+				walkHtmlTokens(token.children);
+			}
+		};
+
+		walkHtmlTokens(tokens);
+	});
+}
+
+export default function(context:any, ruleOptions:any) {
+	return function(md:any, mdOptions:any) {
+		installRule(md, mdOptions, ruleOptions, context);
+	};
+}
diff --git a/ReactNativeClient/lib/joplin-renderer/htmlUtils.js b/ReactNativeClient/lib/joplin-renderer/htmlUtils.js
index 2b775f81..dc78c4a9 100644
--- a/ReactNativeClient/lib/joplin-renderer/htmlUtils.js
+++ b/ReactNativeClient/lib/joplin-renderer/htmlUtils.js
@@ -1,11 +1,14 @@
 const Entities = require('html-entities').AllHtmlEntities;
 const htmlentities = new Entities().encode;
 
 // [\s\S] instead of . for multiline matching
+const NodeHtmlParser = require('node-html-parser');
+
 // https://stackoverflow.com/a/16119722/561309
 const imageRegex = /<img([\s\S]*?)src=["']([\s\S]*?)["']([\s\S]*?)>/gi;
+const JS_EVENT_NAMES = ['onabort', 'onafterprint', 'onbeforeprint', 'onbeforeunload', 'onblur', 'oncanplay', 'oncanplaythrough', 'onchange', 'onclick', 'oncontextmenu', 'oncopy', 'oncuechange', 'oncut', 'ondblclick', 'ondrag', 'ondragend', 'ondragenter', 'ondragleave', 'ondragover', 'ondragstart', 'ondrop', 'ondurationchange', 'onemptied', 'onended', 'onerror', 'onfocus', 'onhashchange', 'oninput', 'oninvalid', 'onkeydown', 'onkeypress', 'onkeyup', 'onload', 'onloadeddata', 'onloadedmetadata', 'onloadstart', 'onmessage', 'onmousedown', 'onmousemove', 'onmouseout', 'onmouseover', 'onmouseup', 'onmousewheel', 'onoffline', 'ononline', 'onpagehide', 'onpageshow', 'onpaste', 'onpause', 'onplay', 'onplaying', 'onpopstate', 'onprogress', 'onratechange', 'onreset', 'onresize', 'onscroll', 'onsearch', 'onseeked', 'onseeking', 'onselect', 'onstalled', 'onstorage', 'onsubmit', 'onsuspend', 'ontimeupdate', 'ontoggle', 'onunload', 'onvolumechange', 'onwaiting', 'onwheel'];
 
 class HtmlUtils {
 
 	attributesHtml(attr) {
 		const output = [];
@@ -41,10 +44,38 @@ class HtmlUtils {
 
 			throw new Error(`Invalid action: ${action.type}`);
 		});
 	}
 
+	sanitizeHtml(html) {
+		const walkHtmlNodes = (nodes) => {
+			if (!nodes || !nodes.length) return;
+
+			for (const node of nodes) {
+				for (const attr in node.attributes) {
+					if (!node.attributes.hasOwnProperty(attr)) continue;
+					if (JS_EVENT_NAMES.includes(attr)) node.setAttribute(attr, '');
+				}
+				walkHtmlNodes(node.childNodes);
+			}
+		};
+
+		// Need to wrap in div, otherwise elements at the root will be skipped
+		// The DIV tags are removed below
+		const dom = NodeHtmlParser.parse(`<div>${html}</div>`, {
+			script: false,
+			style: true,
+			pre: true,
+			comment: false,
+		});
+
+		walkHtmlNodes([dom]);
+		const output = dom.toString();
+		return output.substr(5, output.length - 11);
+	}
+
+
 }
 
 const htmlUtils = new HtmlUtils();
 
 module.exports = htmlUtils;
diff --git a/ReactNativeClient/lib/joplin-renderer/package.json b/ReactNativeClient/lib/joplin-renderer/package.json
index ac1978e5..a3daa853 100644
--- a/ReactNativeClient/lib/joplin-renderer/package.json
+++ b/ReactNativeClient/lib/joplin-renderer/package.json
@@ -35,8 +35,10 @@
     "markdown-it-sub": "^1.0.0",
     "markdown-it-sup": "^1.0.0",
     "markdown-it-toc-done-right": "^4.1.0",
     "md5": "^2.2.1",
     "mermaid": "^8.4.6",
+    "memory-cache": "^0.2.0",
+    "node-html-parser": "^1.2.4",
     "uslug": "^1.0.4"
   }
 }
diff --git a/ReactNativeClient/lib/shim-init-node.js b/ReactNativeClient/lib/shim-init-node.js
index 81c34cb3..e906e5bb 100644
--- a/ReactNativeClient/lib/shim-init-node.js
+++ b/ReactNativeClient/lib/shim-init-node.js
@@ -212,11 +212,11 @@ function shimInit() {
 		if (options === null) options = {};
 
 		if (shim.isElectron()) {
 			const nativeImage = require('electron').nativeImage;
 			let image = nativeImage.createFromDataURL(imageDataUrl);
-			if (image.isEmpty()) throw new Error('Could not convert data URL to image'); // Would throw for example if the image format is no supported (eg. image/gif)
+			if (image.isEmpty()) throw new Error('Could not convert data URL to image - perhaps the format is not supported (eg. image/gif)'); // Would throw for example if the image format is no supported (eg. image/gif)
 			if (options.cropRect) {
 				// Crop rectangle values need to be rounded or the crop() call will fail
 				const c = options.cropRect;
 				if ('x' in c) c.x = Math.round(c.x);
 				if ('y' in c) c.y = Math.round(c.y);
diff --git a/ReactNativeClient/package-lock.json b/ReactNativeClient/package-lock.json
index 86b4632c..4087c19b 100644
--- a/ReactNativeClient/package-lock.json
+++ b/ReactNativeClient/package-lock.json
@@ -5687,10 +5687,15 @@
       "integrity": "sha1-Xt1StIXKHZAP5kiVUFOZoN+kX3Y=",
       "requires": {
         "mimic-fn": "^1.0.0"
       }
     },
+    "memory-cache": {
+      "version": "0.2.0",
+      "resolved": "https://registry.npmjs.org/memory-cache/-/memory-cache-0.2.0.tgz",
+      "integrity": "sha1-eJCwHVLADI68nVM+H46xfjA0hxo="
+    },
     "merge-stream": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-1.0.1.tgz",
       "integrity": "sha1-QEEgLVCKNCugAXQAjfDCUbjBNeE=",
       "requires": {
@@ -6378,10 +6383,25 @@
       "requires": {
         "encoding": "^0.1.11",
         "is-stream": "^1.0.1"
       }
     },
+    "node-html-parser": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/node-html-parser/-/node-html-parser-1.2.4.tgz",
+      "integrity": "sha512-qHwPdGyGr9pOZBoSgUOuNPG20QYZVN00lFcxKQgjPUODSxVH7obQeLVVawa3B4cfSNtLIeczSzoy/xYA8XG5WQ==",
+      "requires": {
+        "he": "1.1.1"
+      },
+      "dependencies": {
+        "he": {
+          "version": "1.1.1",
+          "resolved": "https://registry.npmjs.org/he/-/he-1.1.1.tgz",
+          "integrity": "sha1-k0EP0hsAlzUVH4howvJx80J+I/0="
+        }
+      }
+    },
     "node-int64": {
       "version": "0.4.0",
       "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
       "integrity": "sha1-h6kGXNs1XTGC2PlM4RGIuCXGijs="
     },
diff --git a/ReactNativeClient/package.json b/ReactNativeClient/package.json
index 98a227ca..1f44d59f 100644
--- a/ReactNativeClient/package.json
+++ b/ReactNativeClient/package.json
@@ -70,10 +70,12 @@
     "react-native-sqlite-storage": "^4.1.0",
     "react-native-vector-icons": "^6.6.0",
     "react-native-version-info": "^0.5.1",
     "react-native-webview": "^5.12.0",
     "react-redux": "5.0.7",
+    "memory-cache": "^0.2.0",
+    "node-html-parser": "^1.2.4",
     "redux": "4.0.0",
     "reselect": "^4.0.0",
     "rn-fetch-blob": "^0.12.0",
     "stream": "0.0.2",
     "string-natural-compare": "^2.0.2",

Vulnerable code:     "yargs-parser": "^7.0.0"
		if (['http', 'https', 'file'].indexOf(protocol) >= 0) return url;
      "version": "0.2.1",
      "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-0.2.1.tgz",
      "integrity": "sha512-sK3ujri04WyjwQXVoK4PU3y8ula1stq10GJZpqHIUgoGZdsGzAGu65BnU3d08aTVSvO7mGPZUc0wTEDL+qGE0Q==",
        "domelementtype": {
          "version": "2.0.1",
          "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.0.1.tgz",
          "integrity": "sha512-5HOHUDsYZWV8FGWN0Njbr/Rn7f/eWSQi1v7+HsUVwXgn8nWWlL64zKDkS0n8ZmQ3mlWOMuXOnR+7Nx/5tMO5AQ=="
        },
      "version": "1.3.1",
      "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-1.3.1.tgz",
      "integrity": "sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w=="
      "version": "2.4.2",
      "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-2.4.2.tgz",
      "integrity": "sha512-JiK04h0Ht5u/80fdLMCEmV4zkNh2BcoMFBmZ/91WtYZ8qVXSKjiw7fXMgFPnHcSZgOo3XdinHvmnDUeMf5R4wA==",
        "domelementtype": "1"
      "version": "1.7.0",
      "resolved": "https://registry.npmjs.org/domutils/-/domutils-1.7.0.tgz",
      "integrity": "sha512-Lgd2XcJ/NjEw+7tFvfKxOzCYKZsdct5lczQ2ZaQY8Djz7pfAD3Gbp8ySJWtreII/vDlMVmxwa6pHmdxIYgttDg==",
        "dom-serializer": "0",
        "domelementtype": "1"
      "version": "3.10.1",
      "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-3.10.1.tgz",
      "integrity": "sha512-IgieNijUMbkDovyoKObU1DUhm1iwNYE/fuifEoEHfd1oZKZDaONBSkal7Y01shxsM49R4XaMdGez3WnF9UfiCQ==",
      "requires": {
        "domelementtype": "^1.3.1",
        "domhandler": "^2.3.0",
        "domutils": "^1.5.1",
        "entities": "^1.1.1",
        "inherits": "^2.0.1",
        "readable-stream": "^3.1.1"
        "readable-stream": {
          "version": "3.4.0",
          "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.4.0.tgz",
          "integrity": "sha512-jItXPLmrSR8jmTRmRWJXCnGJsfy85mB3Wd/uINMXA65yrnFo0cPClFIUWzo2najVNSl+mx7/4W8ttlLWJe99pQ==",
          "requires": {
            "inherits": "^2.0.3",
            "string_decoder": "^1.1.1",
            "util-deprecate": "^1.0.1"
          }
        },
        "safe-buffer": {
          "version": "5.2.0",
          "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.0.tgz",
          "integrity": "sha512-fZEwUGbVl7kouZs1jCdMLdt95hdIv0ZeHg6L7qPeciMZhZ+/gdesW4wgTARkrFWEpspjEATAzUGPG8N2jJiwbg=="
        },
        "string_decoder": {
          "version": "1.3.0",
          "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
          "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
          "requires": {
            "safe-buffer": "~5.2.0"
          }
cd "$ROOT_DIR/.."
npm run tsc
	async testing() {
		const markdownUtils = require('lib/markdownUtils');
		const ClipperServer = require('lib/ClipperServer');
		const server = new ClipperServer();
		const HtmlToMd = require('lib/HtmlToMd');
		const service = new HtmlToMd();
		const html = await shim.fsDriver().readFile('/mnt/d/test.html');
		let markdown = service.parse(html, { baseUrl: 'https://duckduckgo.com/' });
		console.info(markdown);
		console.info('--------------------------------------------------');

		const imageUrls = markdownUtils.extractImageUrls(markdown);
		let result = await server.downloadImages_(imageUrls);
		result = await server.createResourcesFromPaths_(result);
		console.info(result);
		markdown = server.replaceImageUrlsByResources_(markdown, result);
		console.info('--------------------------------------------------');
		console.info(markdown);
		console.info('--------------------------------------------------');
	}

	render(markup, theme, options) {
		const html = htmlUtils.processImageTags(markup, data => {
			if (!data.src) return null;

			const r = utils.imageReplacement(this.ResourceModel_, data.src, options.resources, this.resourceBaseUrl_);
			if (!r) return null;

			if (typeof r === 'string') {
				return {
					type: 'replaceElement',
					html: r,
				};
			} else {
				return {
					type: 'setAttributes',
					attrs: r,
				};
			}
		});
	render(markupLanguage, markup, theme, options) {
			if (image.isEmpty()) throw new Error('Could not convert data URL to image'); // Would throw for example if the image format is no supported (eg. image/gif)
Fixed code: ReactNativeClient/lib/joplin-renderer/MdToHtml/rules/sanitize_html.js
ReactNativeClient/lib/joplin-renderer/MdToHtml/rules/sanitize_html.js
    "memory-cache": {
      "version": "0.2.0",
      "resolved": "https://registry.npmjs.org/memory-cache/-/memory-cache-0.2.0.tgz",
      "integrity": "sha1-eJCwHVLADI68nVM+H46xfjA0hxo="
    },
    "node-html-parser": {
      "version": "1.2.4",
      "resolved": "https://registry.npmjs.org/node-html-parser/-/node-html-parser-1.2.4.tgz",
      "integrity": "sha512-qHwPdGyGr9pOZBoSgUOuNPG20QYZVN00lFcxKQgjPUODSxVH7obQeLVVawa3B4cfSNtLIeczSzoy/xYA8XG5WQ==",
      "dev": true,
      "requires": {
        "he": "1.1.1"
      }
    },
    "memory-cache": "^0.2.0",
    "yargs-parser": "^7.0.0",
    "node-html-parser": "^1.2.4"
/* eslint-disable no-unused-vars */

require('app-module-path').addPath(__dirname);

const os = require('os');
const { time } = require('lib/time-utils.js');
const { filename } = require('lib/path-utils.js');
const { asyncTest, fileContentEqual, setupDatabase, setupDatabaseAndSynchronizer, db, synchronizer, fileApi, sleep, clearDatabase, switchClient, syncTargetId, objectsEqual, checkThrowAsync } = require('test-utils.js');
const Folder = require('lib/models/Folder.js');
const Note = require('lib/models/Note.js');
const BaseModel = require('lib/BaseModel.js');
const { shim } = require('lib/shim');
const HtmlToHtml = require('lib/joplin-renderer/HtmlToHtml');
const { enexXmlToMd } = require('lib/import-enex-md-gen.js');

jasmine.DEFAULT_TIMEOUT_INTERVAL = 60 * 60 * 1000; // Can run for a while since everything is in the same test unit

process.on('unhandledRejection', (reason, p) => {
	console.log('Unhandled Rejection at: Promise', p, 'reason:', reason);
});

describe('HtmlToHtml', function() {

	beforeEach(async (done) => {
		await setupDatabaseAndSynchronizer(1);
		await switchClient(1);
		done();
	});

	it('should convert from Html to Html', asyncTest(async () => {
		const basePath = `${__dirname}/html_to_html`;
		const files = await shim.fsDriver().readDirStats(basePath);
		const htmlToHtml = new HtmlToHtml();

		for (let i = 0; i < files.length; i++) {
			const htmlSourceFilename = files[i].path;
			if (htmlSourceFilename.indexOf('.src.html') < 0) continue;

			const htmlSourceFilePath = `${basePath}/${htmlSourceFilename}`;
			const htmlDestPath = `${basePath}/${filename(filename(htmlSourceFilePath))}.dest.html`;

			// if (htmlSourceFilename !== 'table_with_header.html') continue;

			const htmlToHtmlOptions = {
				bodyOnly: true,
			};

			const sourceHtml = await shim.fsDriver().readFile(htmlSourceFilePath);
			let expectedHtml = await shim.fsDriver().readFile(htmlDestPath);

			const result = await htmlToHtml.render(sourceHtml, null, htmlToHtmlOptions);
			let actualHtml = result.html;

			if (os.EOL === '\r\n') {
				expectedHtml = expectedHtml.replace(/\r\n/g, '\n');
				actualHtml = actualHtml.replace(/\r\n/g, '\n');
			}

			if (actualHtml !== expectedHtml) {
				console.info('');
				console.info(`Error converting file: ${htmlSourceFilename}`);
				console.info('--------------------------------- Got:');
				console.info(actualHtml);
				console.info('--------------------------------- Raw:');
				console.info(actualHtml.split('\n'));
				console.info('--------------------------------- Expected:');
				console.info(expectedHtml.split('\n'));
				console.info('--------------------------------------------');
				console.info('');

				expect(false).toBe(true);
				// return;
			} else {
				expect(true).toBe(true);
			}
		}
	}));

});
/* eslint-disable no-unused-vars */

require('app-module-path').addPath(__dirname);

const os = require('os');
const { time } = require('lib/time-utils.js');
const { filename } = require('lib/path-utils.js');
const { asyncTest, fileContentEqual, setupDatabase, setupDatabaseAndSynchronizer, db, synchronizer, fileApi, sleep, clearDatabase, switchClient, syncTargetId, objectsEqual, checkThrowAsync } = require('test-utils.js');
const Folder = require('lib/models/Folder.js');
const Note = require('lib/models/Note.js');
const BaseModel = require('lib/BaseModel.js');
const { shim } = require('lib/shim');
const MdToHtml = require('lib/joplin-renderer/MdToHtml');
const { enexXmlToMd } = require('lib/import-enex-md-gen.js');

jasmine.DEFAULT_TIMEOUT_INTERVAL = 60 * 60 * 1000; // Can run for a while since everything is in the same test unit

process.on('unhandledRejection', (reason, p) => {
	console.log('Unhandled Rejection at: Promise', p, 'reason:', reason);
});

describe('MdToHtml', function() {

	beforeEach(async (done) => {
		await setupDatabaseAndSynchronizer(1);
		await switchClient(1);
		done();
	});

	it('should convert from Markdown to Html', asyncTest(async () => {
		const basePath = `${__dirname}/md_to_html`;
		const files = await shim.fsDriver().readDirStats(basePath);
		const mdToHtml = new MdToHtml();

		for (let i = 0; i < files.length; i++) {
			const mdFilename = files[i].path;
			if (mdFilename.indexOf('.md') < 0) continue;

			const mdFilePath = `${basePath}/${mdFilename}`;
			const htmlPath = `${basePath}/${filename(mdFilePath)}.html`;

			// if (mdFilename !== 'table_with_header.html') continue;

			const mdToHtmlOptions = {
				bodyOnly: true,
			};

			const markdown = await shim.fsDriver().readFile(mdFilePath);
			let expectedHtml = await shim.fsDriver().readFile(htmlPath);

			const result = await mdToHtml.render(markdown, null, mdToHtmlOptions);
			let actualHtml = result.html;

			if (os.EOL === '\r\n') {
				expectedHtml = expectedHtml.replace(/\r\n/g, '\n');
				actualHtml = actualHtml.replace(/\r\n/g, '\n');
			}

			if (actualHtml !== expectedHtml) {
				console.info('');
				console.info(`Error converting file: ${mdFilename}`);
				console.info('--------------------------------- Got:');
				console.info(actualHtml);
				console.info('--------------------------------- Raw:');
				console.info(actualHtml.split('\n'));
				console.info('--------------------------------- Expected:');
				console.info(expectedHtml.split('\n'));
				console.info('--------------------------------------------');
				console.info('');

				expect(false).toBe(true);
				// return;
			} else {
				expect(true).toBe(true);
			}
		}
	}));

});
<img src onerror="" />
<img src onerror="" />
<img src="" onerror="alert('ohno')"/>
<img src=""
   onerror="alert('ohno')"/>
<img src onerror="" />
<img src onerror="" />
<img src="" onerror="alert('ohno')"/>
<img src=""
   onerror="alert('ohno')"/>
		if (['http', 'https', 'file', 'data'].indexOf(protocol) >= 0) return url;
        "domelementtype": {
          "version": "1.3.1",
          "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-1.3.1.tgz",
          "integrity": "sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w=="
        },
        "domhandler": {
          "version": "2.4.2",
          "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-2.4.2.tgz",
          "integrity": "sha512-JiK04h0Ht5u/80fdLMCEmV4zkNh2BcoMFBmZ/91WtYZ8qVXSKjiw7fXMgFPnHcSZgOo3XdinHvmnDUeMf5R4wA==",
          "requires": {
            "domelementtype": "1"
          }
        },
        "domutils": {
          "version": "1.7.0",
          "resolved": "https://registry.npmjs.org/domutils/-/domutils-1.7.0.tgz",
          "integrity": "sha512-Lgd2XcJ/NjEw+7tFvfKxOzCYKZsdct5lczQ2ZaQY8Djz7pfAD3Gbp8ySJWtreII/vDlMVmxwa6pHmdxIYgttDg==",
          "requires": {
            "dom-serializer": "0",
            "domelementtype": "1"
          }
        },
        "htmlparser2": {
          "version": "3.10.1",
          "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-3.10.1.tgz",
          "integrity": "sha512-IgieNijUMbkDovyoKObU1DUhm1iwNYE/fuifEoEHfd1oZKZDaONBSkal7Y01shxsM49R4XaMdGez3WnF9UfiCQ==",
          "requires": {
            "domelementtype": "^1.3.1",
            "domhandler": "^2.3.0",
            "domutils": "^1.5.1",
            "entities": "^1.1.1",
            "inherits": "^2.0.1",
            "readable-stream": "^3.1.1"
          }
        },
        },
        "readable-stream": {
          "version": "3.6.0",
          "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz",
          "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==",
          "requires": {
            "inherits": "^2.0.3",
            "string_decoder": "^1.1.1",
            "util-deprecate": "^1.0.1"
          }
        },
        "safe-buffer": {
          "version": "5.2.0",
          "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.0.tgz",
          "integrity": "sha512-fZEwUGbVl7kouZs1jCdMLdt95hdIv0ZeHg6L7qPeciMZhZ+/gdesW4wgTARkrFWEpspjEATAzUGPG8N2jJiwbg=="
        },
        "string_decoder": {
          "version": "1.3.0",
          "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
          "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
          "requires": {
            "safe-buffer": "~5.2.0"
          }
      "version": "0.2.2",
      "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-0.2.2.tgz",
      "integrity": "sha512-2/xPb3ORsQ42nHYiSunXkDjPLBaEj/xTwUO4B7XCZQTRk7EBtTOPaygh10YAAh2OI1Qrp6NWfpAhzswj0ydt9g==",
      "version": "2.0.1",
      "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.0.1.tgz",
      "integrity": "sha512-5HOHUDsYZWV8FGWN0Njbr/Rn7f/eWSQi1v7+HsUVwXgn8nWWlL64zKDkS0n8ZmQ3mlWOMuXOnR+7Nx/5tMO5AQ=="
      "version": "3.0.0",
      "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-3.0.0.tgz",
      "integrity": "sha512-eKLdI5v9m67kbXQbJSNn1zjh0SDzvzWVWtX+qEI3eMjZw8daH9k8rlj1FZY9memPwjiskQFbe7vHVVJIAqoEhw==",
        "domelementtype": "^2.0.1"
      "version": "2.0.0",
      "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.0.0.tgz",
      "integrity": "sha512-n5SelJ1axbO636c2yUtOGia/IcJtVtlhQbFiVDBZHKV5ReJO1ViX7sFEemtuyoAnBxk5meNSYgA8V4s0271efg==",
        "dom-serializer": "^0.2.1",
        "domelementtype": "^2.0.1",
        "domhandler": "^3.0.0"
      "version": "4.0.0",
      "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-4.0.0.tgz",
      "integrity": "sha512-cChwXn5Vam57fyXajDtPXL1wTYc8JtLbr2TN76FYu05itVVVealxLowe2B3IEznJG4p9HAYn/0tJaRlGuEglFQ==",
      "requires": {
        "domelementtype": "^2.0.1",
        "domhandler": "^3.0.0",
        "domutils": "^2.0.0",
        "entities": "^2.0.0"
        "entities": {
          "version": "2.0.0",
          "resolved": "https://registry.npmjs.org/entities/-/entities-2.0.0.tgz",
          "integrity": "sha512-D9f7V0JSRwIxlRI2mjMqufDrRDnx8p+eEOz7aUM9SuvF8gsBzra0/6tbjl1m8eQHrZlYj6PxqE00hZ1SAIKPLw=="
    "memory-cache": {
      "version": "0.2.0",
      "resolved": "https://registry.npmjs.org/memory-cache/-/memory-cache-0.2.0.tgz",
      "integrity": "sha1-eJCwHVLADI68nVM+H46xfjA0hxo="
    },
    "node-html-parser": {
      "version": "1.2.4",
      "resolved": "https://registry.npmjs.org/node-html-parser/-/node-html-parser-1.2.4.tgz",
      "integrity": "sha512-qHwPdGyGr9pOZBoSgUOuNPG20QYZVN00lFcxKQgjPUODSxVH7obQeLVVawa3B4cfSNtLIeczSzoy/xYA8XG5WQ==",
      "requires": {
        "he": "1.1.1"
      },
      "dependencies": {
        "he": {
          "version": "1.1.1",
          "resolved": "https://registry.npmjs.org/he/-/he-1.1.1.tgz",
          "integrity": "sha1-k0EP0hsAlzUVH4howvJx80J+I/0="
        }
      }
    },
    "memory-cache": "^0.2.0",
    "node-html-parser": "^1.2.4",
# cd "$ROOT_DIR/.."
# npm run tsc
const memoryCache = require('memory-cache');
const md5 = require('md5');
		this.cache_ = new memoryCache.Cache();
	async render(markup, theme, options) {
		const cacheKey = md5(escape(markup));
		let html = this.cache_.get(cacheKey);

		if (!html) {
			html = htmlUtils.sanitizeHtml(markup);

			html = htmlUtils.processImageTags(html, data => {
				if (!data.src) return null;

				const r = utils.imageReplacement(this.ResourceModel_, data.src, options.resources, this.resourceBaseUrl_);
				if (!r) return null;

				if (typeof r === 'string') {
					return {
						type: 'replaceElement',
						html: r,
					};
				} else {
					return {
						type: 'setAttributes',
						attrs: r,
					};
				}
			});
		}

		if (options.bodyOnly) return {
			html: html,
			pluginAssets: [],
		};

		this.cache_.put(cacheKey, html, 1000 * 60 * 10);
	async render(markupLanguage, markup, theme, options) {
const memoryCache = require('memory-cache');
	sanitize_html: require('./MdToHtml/rules/sanitize_html').default,
		this.contextCache_ = new memoryCache.Cache();
		if (!('bodyOnly' in options)) options.bodyOnly = false;
			cache: this.contextCache_,
		markdownIt.use(rules.sanitize_html(context, ruleOptions));
const md5 = require('md5');
const htmlUtils = require('../../htmlUtils');

// @ts-ignore: Keep the function signature as-is despite unusued arguments
function installRule(markdownIt:any, mdOptions:any, ruleOptions:any, context:any) {
	markdownIt.core.ruler.push('sanitize_html', (state:any) => {
		const tokens = state.tokens;

		const walkHtmlTokens = (tokens:any[]) => {
			if (!tokens || !tokens.length) return;

			for (const token of tokens) {
				if (!['html_block', 'html_inline'].includes(token.type)) {
					walkHtmlTokens(token.children);
					continue;
				}

				const cacheKey = md5(escape(token.content));
				let sanitizedContent = context.cache.get(cacheKey);

				if (!sanitizedContent) {
					sanitizedContent = htmlUtils.sanitizeHtml(token.content);
				}

				token.content = sanitizedContent;

				context.cache.put(cacheKey, sanitizedContent, 1000 * 60 * 60);
				walkHtmlTokens(token.children);
			}
		};

		walkHtmlTokens(tokens);
	});
}

export default function(context:any, ruleOptions:any) {
	return function(md:any, mdOptions:any) {
		installRule(md, mdOptions, ruleOptions, context);
	};
}
const NodeHtmlParser = require('node-html-parser');

const JS_EVENT_NAMES = ['onabort', 'onafterprint', 'onbeforeprint', 'onbeforeunload', 'onblur', 'oncanplay', 'oncanplaythrough', 'onchange', 'onclick', 'oncontextmenu', 'oncopy', 'oncuechange', 'oncut', 'ondblclick', 'ondrag', 'ondragend', 'ondragenter', 'ondragleave', 'ondragover', 'ondragstart', 'ondrop', 'ondurationchange', 'onemptied', 'onended', 'onerror', 'onfocus', 'onhashchange', 'oninput', 'oninvalid', 'onkeydown', 'onkeypress', 'onkeyup', 'onload', 'onloadeddata', 'onloadedmetadata', 'onloadstart', 'onmessage', 'onmousedown', 'onmousemove', 'onmouseout', 'onmouseover', 'onmouseup', 'onmousewheel', 'onoffline', 'ononline', 'onpagehide', 'onpageshow', 'onpaste', 'onpause', 'onplay', 'onplaying', 'onpopstate', 'onprogress', 'onratechange', 'onreset', 'onresize', 'onscroll', 'onsearch', 'onseeked', 'onseeking', 'onselect', 'onstalled', 'onstorage', 'onsubmit', 'onsuspend', 'ontimeupdate', 'ontoggle', 'onunload', 'onvolumechange', 'onwaiting', 'onwheel'];
	sanitizeHtml(html) {
		const walkHtmlNodes = (nodes) => {
			if (!nodes || !nodes.length) return;

			for (const node of nodes) {
				for (const attr in node.attributes) {
					if (!node.attributes.hasOwnProperty(attr)) continue;
					if (JS_EVENT_NAMES.includes(attr)) node.setAttribute(attr, '');
				}
				walkHtmlNodes(node.childNodes);
			}
		};

		// Need to wrap in div, otherwise elements at the root will be skipped
		// The DIV tags are removed below
		const dom = NodeHtmlParser.parse(`<div>${html}</div>`, {
			script: false,
			style: true,
			pre: true,
			comment: false,
		});

		walkHtmlNodes([dom]);
		const output = dom.toString();
		return output.substr(5, output.length - 11);
	}


    "memory-cache": "^0.2.0",
    "node-html-parser": "^1.2.4",
			if (image.isEmpty()) throw new Error('Could not convert data URL to image - perhaps the format is not supported (eg. image/gif)'); // Would throw for example if the image format is no supported (eg. image/gif)
    "memory-cache": {
      "version": "0.2.0",
      "resolved": "https://registry.npmjs.org/memory-cache/-/memory-cache-0.2.0.tgz",
      "integrity": "sha1-eJCwHVLADI68nVM+H46xfjA0hxo="
    },
    "node-html-parser": {
      "version": "1.2.4",
      "resolved": "https://registry.npmjs.org/node-html-parser/-/node-html-parser-1.2.4.tgz",
      "integrity": "sha512-qHwPdGyGr9pOZBoSgUOuNPG20QYZVN00lFcxKQgjPUODSxVH7obQeLVVawa3B4cfSNtLIeczSzoy/xYA8XG5WQ==",
      "requires": {
        "he": "1.1.1"
      },
      "dependencies": {
        "he": {
          "version": "1.1.1",
          "resolved": "https://registry.npmjs.org/he/-/he-1.1.1.tgz",
          "integrity": "sha1-k0EP0hsAlzUVH4howvJx80J+I/0="
        }
      }
    },
    "memory-cache": "^0.2.0",
    "node-html-parser": "^1.2.4",