CVE-2020-9038
Description
Joplin through 1.0.184 allows Arbitrary File Read via XSS.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Joplin through 1.0.184 allows arbitrary file read on the host system via cross-site scripting (XSS) in note content.
Vulnerability
CVE-2020-9038 is a security vulnerability in Joplin, an open-source note-taking application, affecting versions up to 1.0.184. The vulnerability enables arbitrary file read on the host system through cross-site scripting (XSS) [1]. The root cause lies in insufficient sanitization of HTML content within notes, allowing an attacker to inject malicious scripts that execute in the application's rendering context.
Exploitation
To exploit this, an attacker must craft a note containing malicious HTML/JavaScript that bypasses the sanitizer. When a victim opens or previews this note, the script executes within the Electron application's context, which has elevated privileges to access the file system [2]. No additional authentication bypass is needed because the attack leverages the note content itself, and notes can be shared or synced across devices.
Impact
Successful exploitation allows an attacker to read arbitrary files from the victim's local file system, potentially exfiltrating sensitive data such as passwords, tokens, or private keys. The impact is significant because Joplin is used for personal note-taking and may contain confidential information.
Mitigation
The vulnerability was patched in Joplin version 1.0.185, which introduced additional HTML sanitization using the node-html-parser library and a caching mechanism to prevent re-execution [3][4]. Users should update to the latest version to protect against this attack. No workarounds are known for unpatched versions.
AI Insight generated on May 21, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
joplinnpm | < 1.2.1 | 1.2.1 |
Affected products
2- Joplin/Joplindescription
Patches
13db47b575b9cAll: Security: Fixed potential Arbitrary File Read via XSS
24 files changed · +437 −98
CliClient/package.json+3 −1 modified@@ -72,6 +72,7 @@ "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", @@ -104,7 +105,8 @@ "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"
CliClient/package-lock.json+14 −0 modified@@ -2667,6 +2667,11 @@ "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", @@ -2920,6 +2925,15 @@ "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",
CliClient/tests/HtmlToHtml.js+79 −0 added@@ -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); + } + } + })); + +});
CliClient/tests/html_to_html/sanitize.dest.html+2 −0 added@@ -0,0 +1,2 @@ +<img src onerror="" /> +<img src onerror="" /> \ No newline at end of file
CliClient/tests/html_to_html/sanitize.src.html+3 −0 added@@ -0,0 +1,3 @@ +<img src="" onerror="alert('ohno')"/> +<img src="" + onerror="alert('ohno')"/> \ No newline at end of file
CliClient/tests/MdToHtml.js+79 −0 added@@ -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); + } + } + })); + +});
CliClient/tests/md_to_html/sanitize.html+2 −0 added@@ -0,0 +1,2 @@ +<img src onerror="" /> +<img src onerror="" /> \ No newline at end of file
CliClient/tests/md_to_html/sanitize.md+3 −0 added@@ -0,0 +1,3 @@ +<img src="" onerror="alert('ohno')"/> +<img src="" + onerror="alert('ohno')"/> \ No newline at end of file
Clipper/joplin-webclipper/content_scripts/index.js+1 −1 modified@@ -21,7 +21,7 @@ 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;
ElectronClient/app/package.json+2 −0 modified@@ -131,11 +131,13 @@ "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",
ElectronClient/app/package-lock.json+106 −52 modified@@ -2233,10 +2233,68 @@ "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" + } } } }, @@ -3158,19 +3216,14 @@ } }, "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", @@ -3179,9 +3232,9 @@ } }, "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", @@ -3192,20 +3245,21 @@ } }, "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": { @@ -4640,40 +4694,20 @@ } }, "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==" } } }, @@ -5582,6 +5616,11 @@ "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", @@ -6183,6 +6222,21 @@ } } }, + "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",
ElectronClient/build.sh+2 −2 modified@@ -5,8 +5,8 @@ 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
.eslintignore+1 −0 modified@@ -55,3 +55,4 @@ 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
.gitignore+1 −0 modified@@ -51,3 +51,4 @@ 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
ReactNativeClient/lib/BaseApplication.js+0 −21 modified@@ -539,27 +539,6 @@ class BaseApplication { 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);
ReactNativeClient/lib/joplin-renderer/HtmlToHtml.js+36 −19 modified@@ -1,33 +1,50 @@ 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>`;
ReactNativeClient/lib/joplin-renderer/htmlUtils.js+31 −0 modified@@ -2,8 +2,11 @@ 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 { @@ -43,6 +46,34 @@ class HtmlUtils { }); } + 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();
ReactNativeClient/lib/joplin-renderer/MarkupToHtml.js+1 −1 modified@@ -33,7 +33,7 @@ class MarkupToHtml { return ''; } - render(markupLanguage, markup, theme, options) { + async render(markupLanguage, markup, theme, options) { return this.renderer(markupLanguage).render(markup, theme, options); } }
ReactNativeClient/lib/joplin-renderer/MdToHtml.js+6 −0 modified@@ -2,6 +2,7 @@ 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'), @@ -12,6 +13,7 @@ const rules = { 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'); @@ -50,6 +52,7 @@ class MdToHtml { this.cachedHighlightedCode_ = {}; this.ResourceModel_ = options.ResourceModel; this.pluginOptions_ = options.pluginOptions ? options.pluginOptions : {}; + this.contextCache_ = new memoryCache.Cache(); } pluginOptions(name) { @@ -106,6 +109,7 @@ 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 = []; @@ -129,6 +133,7 @@ class MdToHtml { const context = { css: {}, pluginAssets: {}, + cache: this.contextCache_, }; const ruleOptions = Object.assign({}, options, { @@ -203,6 +208,7 @@ class MdToHtml { 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 });
ReactNativeClient/lib/joplin-renderer/MdToHtml/rules/sanitize_html.ts+40 −0 added@@ -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); + }; +}
ReactNativeClient/lib/joplin-renderer/package.json+2 −0 modified@@ -37,6 +37,8 @@ "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" } }
ReactNativeClient/lib/shim-init-node.js+1 −1 modified@@ -214,7 +214,7 @@ function shimInit() { 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;
ReactNativeClient/package.json+2 −0 modified@@ -72,6 +72,8 @@ "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",
ReactNativeClient/package-lock.json+20 −0 modified@@ -5689,6 +5689,11 @@ "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", @@ -6380,6 +6385,21 @@ "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",
Vulnerability mechanics
Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
5- github.com/advisories/GHSA-6r7x-hc8m-985rghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2020-9038ghsaADVISORY
- packetstormsecurity.com/files/156582/Joplin-Desktop-1.0.184-Cross-Site-Scripting.htmlghsax_refsource_MISCWEB
- github.com/laurent22/joplin/commit/3db47b575b9cb0a765da3d283baa2c065df0d0bcghsax_refsource_MISCWEB
- github.com/laurent22/joplin/compare/clipper-1.0.19...clipper-1.0.20ghsax_refsource_MISCWEB
News mentions
0No linked articles in our index yet.