diff --git a/package.json b/package.json index 0a1df880..6d8826d8 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "file-loader": "^6.2.0", "fs-extra": "^9.0.1", "html-webpack-plugin": "^4.5.0", + "jsdom": "^29.1.1", "json-loader": "^0.5.7", "jstrace": "^0.3.0", "less": "^3.13.1", @@ -30,6 +31,7 @@ "run-all": "^1.0.1", "run-s": "0.0.0", "style-loader": "^2.0.0", + "terser-webpack-plugin": "^5.6.1", "trash": "^7.1.0", "webpack": "^5.11.0", "webpack-cli": "^4.7.0", @@ -40,7 +42,7 @@ "start": "webpack serve --config webpack.dev.js --open", "build": "webpack --config webpack.prd.js && node tasks/compile_less.js", "clean": "trash dist", - "test": "echo \"Error: no test specified\" && exit 1", + "test": "node --test \"tests/js/**/*.test.mjs\"", "dist": "npm-run-all -s clean build && cp dist/js/storymap.js dist/js/storymap-min.js", "stage": "npm run dist && node tasks/stage.js", "stage_latest": "npm run dist && node tasks/stage.js latest", diff --git a/src/js/media/EmbedUtil.js b/src/js/media/EmbedUtil.js new file mode 100644 index 00000000..2c7ffba0 --- /dev/null +++ b/src/js/media/EmbedUtil.js @@ -0,0 +1,118 @@ +/* EmbedUtil + Utilities for safely rendering user-pasted embed snippets. + + The media "url" field is overloaded: for the iframe and blockquote + media types it holds raw HTML pasted by the user (that markup is + what routes the media to those types in MediaType.js). These helpers + parse that HTML inertly -- DOMParser never executes scripts or event + handlers -- and rebuild clean DOM from an allowlist, so markup stored + in a storymap's JSON can never execute code in a viewer's browser. +================================================== */ + +// Attributes copied from a pasted ' + ); + assert.ok(iframe); + assert.equal(iframe.tagName, "IFRAME"); + assert.equal(iframe.getAttribute("src"), "https://www.youtube.com/embed/abc123"); + assert.equal(iframe.getAttribute("width"), "560"); + assert.equal(iframe.getAttribute("height"), "315"); + assert.equal(iframe.getAttribute("frameborder"), "0"); + assert.ok(iframe.hasAttribute("allowfullscreen")); +}); + +test("buildIframe strips event handlers and unknown attributes", () => { + const iframe = buildIframe( + '' + ); + assert.ok(iframe); + assert.equal(iframe.getAttribute("onload"), null); + assert.equal(iframe.getAttribute("name"), null); + assert.equal(iframe.getAttribute("srcdoc"), null); +}); + +test("buildIframe discards markup outside the iframe", () => { + const iframe = buildIframe( + '' + ); + assert.ok(iframe); + assert.equal(iframe.tagName, "IFRAME"); + assert.equal(iframe.getAttribute("src"), "https://example.com/"); +}); + +test("buildIframe rejects javascript: src", () => { + assert.equal(buildIframe(''), null); +}); + +test("buildIframe rejects an iframe with no src", () => { + assert.equal(buildIframe(""), null); +}); + +test("buildIframe accepts a bare URL that routed to the iframe type", () => { + const iframe = buildIframe("https://example.com/iframe-demo"); + assert.ok(iframe); + assert.equal(iframe.getAttribute("src"), "https://example.com/iframe-demo"); + assert.equal(iframe.getAttribute("width"), "100%"); +}); + +test("buildIframe returns null for text that is neither embed nor URL", () => { + assert.equal(buildIframe("this mentions iframe but embeds nothing"), null); +}); + +/* sanitizeBlockquote +================================================== */ + +function renderedHTML(fragment) { + const div = document.createElement("div"); + div.appendChild(fragment); + return div.innerHTML; +} + +test("sanitizeBlockquote keeps quote structure and text", () => { + const html = renderedHTML( + sanitizeBlockquote("

Truth is rarely pure.

Oscar Wilde
") + ); + assert.equal(html, "

Truth is rarely pure.

Oscar Wilde
"); +}); + +test("sanitizeBlockquote drops script tags and their contents", () => { + const html = renderedHTML( + sanitizeBlockquote("
quote
") + ); + assert.equal(html, "
quote
"); +}); + +test("sanitizeBlockquote strips event handler and style attributes", () => { + const html = renderedHTML( + sanitizeBlockquote('
quote
') + ); + assert.equal(html, "
quote
"); +}); + +test("sanitizeBlockquote unwraps unknown tags but keeps their text", () => { + const html = renderedHTML( + sanitizeBlockquote("
quote

heading

") + ); + assert.equal(html, "
quote
heading"); +}); + +test("sanitizeBlockquote drops img-based XSS entirely", () => { + const html = renderedHTML( + sanitizeBlockquote('
q
') + ); + assert.equal(html, "
q
"); +}); + +test("sanitizeBlockquote keeps links with valid href, drops javascript: href", () => { + const good = renderedHTML( + sanitizeBlockquote('
link
') + ); + assert.equal(good, '
link
'); + + const bad = renderedHTML( + sanitizeBlockquote('
link
') + ); + assert.equal(bad, "
link
"); +});