Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand Down
118 changes: 118 additions & 0 deletions src/js/media/EmbedUtil.js
Original file line number Diff line number Diff line change
@@ -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 <iframe> onto the rebuilt element.
// src is handled separately (validated); everything else is dropped.
const IFRAME_ATTRIBUTES = ["width", "height", "frameborder", "allowfullscreen", "allow", "scrolling", "title"];

// Tags allowed to survive blockquote sanitization. Elements not listed
// are unwrapped (their children are kept); DROP_TAGS are removed along
// with their contents.
const BLOCKQUOTE_TAGS = ["BLOCKQUOTE", "P", "CITE", "EM", "STRONG", "B", "I", "U", "Q", "A", "BR", "SPAN", "SMALL", "SUP", "SUB", "FOOTER", "UL", "OL", "LI"];
const DROP_TAGS = ["SCRIPT", "STYLE", "TEMPLATE", "IFRAME", "FRAME", "OBJECT", "EMBED", "APPLET", "LINK", "META", "BASE", "SVG", "MATH", "FORM", "INPUT", "BUTTON", "TEXTAREA", "SELECT"];

function parseInert(html) {
return new DOMParser().parseFromString(html, "text/html");
}

/* Return an absolute http(s) URL for url, or null if it is empty,
unparseable, or uses any other protocol (javascript:, data:, ...).
Relative and protocol-relative URLs resolve against the document.
================================================== */
export function validateWebURL(url) {
if (!url) {
return null;
}
var a = document.createElement("a");
a.href = url;
if (a.protocol === "http:" || a.protocol === "https:") {
return a.href;
}
return null;
}

/* Build a clean <iframe> element from a pasted embed snippet.
Only the validated src and allowlisted presentation attributes
survive; event handlers, extra tags and scripts are discarded.
Also accepts a bare URL for the src. Returns null if no safe
src can be extracted.
================================================== */
export function buildIframe(html) {
var pasted = parseInert(html).querySelector("iframe");
var src = null;

if (pasted) {
src = validateWebURL(pasted.getAttribute("src"));
} else if (/^https?:\/\/\S+$/i.test(html.trim())) {
// The field held a bare URL rather than an embed snippet
src = validateWebURL(html.trim());
}
if (!src) {
return null;
}

var iframe = document.createElement("iframe");
iframe.setAttribute("src", src);
if (pasted) {
for (var i = 0; i < IFRAME_ATTRIBUTES.length; i++) {
if (pasted.hasAttribute(IFRAME_ATTRIBUTES[i])) {
iframe.setAttribute(IFRAME_ATTRIBUTES[i], pasted.getAttribute(IFRAME_ATTRIBUTES[i]));
}
}
} else {
iframe.setAttribute("width", "100%");
iframe.setAttribute("height", "100%");
iframe.setAttribute("frameborder", "0");
iframe.setAttribute("allowfullscreen", "");
}
return iframe;
}

/* Sanitize pasted blockquote markup into a DocumentFragment.
Allowlisted formatting tags are kept (with all attributes stripped,
except a validated href on links), unknown tags are unwrapped, and
executable/embedding tags are dropped with their contents.
================================================== */
export function sanitizeBlockquote(html) {
var fragment = document.createDocumentFragment();
appendSanitized(parseInert(html).body, fragment);
return fragment;
}

function appendSanitized(node, parent) {
for (var i = 0; i < node.childNodes.length; i++) {
var child = node.childNodes[i];
if (child.nodeType === 3) {
parent.appendChild(document.createTextNode(child.nodeValue));
} else if (child.nodeType === 1) {
var tag = child.nodeName.toUpperCase();
if (DROP_TAGS.indexOf(tag) !== -1) {
continue;
}
if (BLOCKQUOTE_TAGS.indexOf(tag) !== -1) {
var el = document.createElement(tag);
if (tag === "A") {
var href = validateWebURL(child.getAttribute("href"));
if (href) {
el.setAttribute("href", href);
el.setAttribute("target", "_blank");
}
}
appendSanitized(child, el);
parent.appendChild(el);
} else {
// Unknown tag: unwrap, keeping its children
appendSanitized(child, parent);
}
}
// Comments and other node types are dropped
}
}
21 changes: 12 additions & 9 deletions src/js/media/types/Blockquote.js
Original file line number Diff line number Diff line change
@@ -1,29 +1,32 @@
import { Media } from "../Media"
import Dom from "../../dom/Dom"
import { Language } from "../../language/Language"
import { Language } from "../../language/Language"
import { sanitizeBlockquote } from "../EmbedUtil"


/* Media.Blockquote
================================================== */

export default class Blockquote extends Media {

/* Load the media
================================================== */
_loadMedia() {

// Loading Message
this.message.updateMessage(Language.messages.loading + " " + this.options.media_name);

// Create Dom element
this._el.content_item = Dom.create("div", "vco-media-item vco-media-blockquote", this._el.content);

// Get Media ID
this.media_id = this.data.url;

// API Call
this._el.content_item.innerHTML = this.media_id;


// The url field holds user-pasted blockquote markup. Sanitize it
// instead of injecting the raw markup, which would allow stored
// XSS via the storymap JSON.
this._el.content_item.appendChild(sanitizeBlockquote(this.media_id));

// After Loaded
this.onLoaded();
}
Expand Down
28 changes: 18 additions & 10 deletions src/js/media/types/IFrame.js
Original file line number Diff line number Diff line change
@@ -1,32 +1,40 @@
import { Media } from "../Media"
import Dom from "../../dom/Dom"
import { Language } from "../../language/Language"
import { Language } from "../../language/Language"
import { buildIframe } from "../EmbedUtil"

/* Media.IFrame
================================================== */

export default class IFrame extends Media {

/* Load the media
================================================== */
_loadMedia() {
self = this;

// Loading Message
this.message.updateMessage(Language.messages.loading + " " + this.options.media_name);

// Create Dom element
this._el.content_item = Dom.create("div", "vco-media-item vco-media-iframe", this._el.content);

// Get Media ID
this.media_id = this.data.url;

// API URL
let iframe = this.media_id;

// The url field holds a user-pasted embed snippet. Rebuild a
// clean iframe from its src instead of injecting the raw markup,
// which would allow stored XSS via the storymap JSON.
let iframe = buildIframe(this.media_id);

if (!iframe) {
this.loadErrorDisplay("Invalid embed code. Paste an iframe embed code with an http(s) source URL.");
return;
}

// API Call
this._el.content_item.innerHTML = iframe;
this._el.content_item.appendChild(iframe);

// After Loaded
this.onLoaded();
}
Expand Down
148 changes: 148 additions & 0 deletions tests/js/embed_util.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
/* Tests for src/js/media/EmbedUtil.js
Run with: npm test (node --test, jsdom provides the DOM)
================================================== */
import { test } from "node:test";
import assert from "node:assert/strict";
import { JSDOM } from "jsdom";

const dom = new JSDOM("<!doctype html><html><body></body></html>", {
url: "https://storymap.knightlab.com/edit/",
});
global.window = dom.window;
global.document = dom.window.document;
global.DOMParser = dom.window.DOMParser;

const { buildIframe, sanitizeBlockquote, validateWebURL } = await import(
"../../src/js/media/EmbedUtil.js"
);

/* validateWebURL
================================================== */

test("validateWebURL accepts http and https", () => {
assert.equal(validateWebURL("https://example.com/a"), "https://example.com/a");
assert.equal(validateWebURL("http://example.com/a"), "http://example.com/a");
});

test("validateWebURL resolves relative and protocol-relative URLs", () => {
assert.equal(validateWebURL("/foo"), "https://storymap.knightlab.com/foo");
assert.equal(validateWebURL("//example.com/x"), "https://example.com/x");
});

test("validateWebURL rejects non-web protocols and empty values", () => {
assert.equal(validateWebURL("javascript:alert(1)"), null);
assert.equal(validateWebURL("data:text/html,<script>alert(1)</script>"), null);
assert.equal(validateWebURL("vbscript:x"), null);
assert.equal(validateWebURL(""), null);
assert.equal(validateWebURL(null), null);
});

/* buildIframe
================================================== */

test("buildIframe extracts src and presentation attributes from an embed code", () => {
const iframe = buildIframe(
'<iframe src="https://www.youtube.com/embed/abc123" width="560" height="315" frameborder="0" allowfullscreen></iframe>'
);
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(
'<iframe src="https://example.com/" onload="alert(1)" name="evil" srcdoc="<script>alert(1)</script>"></iframe>'
);
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(
'<img src=x onerror="alert(1)"><iframe src="https://example.com/"></iframe><script>alert(1)</script>'
);
assert.ok(iframe);
assert.equal(iframe.tagName, "IFRAME");
assert.equal(iframe.getAttribute("src"), "https://example.com/");
});

test("buildIframe rejects javascript: src", () => {
assert.equal(buildIframe('<iframe src="javascript:alert(1)"></iframe>'), null);
});

test("buildIframe rejects an iframe with no src", () => {
assert.equal(buildIframe("<iframe></iframe>"), 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("<blockquote><p>Truth is <em>rarely</em> pure.</p><cite>Oscar Wilde</cite></blockquote>")
);
assert.equal(html, "<blockquote><p>Truth is <em>rarely</em> pure.</p><cite>Oscar Wilde</cite></blockquote>");
});

test("sanitizeBlockquote drops script tags and their contents", () => {
const html = renderedHTML(
sanitizeBlockquote("<blockquote>quote</blockquote><script>alert(1)</script>")
);
assert.equal(html, "<blockquote>quote</blockquote>");
});

test("sanitizeBlockquote strips event handler and style attributes", () => {
const html = renderedHTML(
sanitizeBlockquote('<blockquote onclick="alert(1)" style="color:red">quote</blockquote>')
);
assert.equal(html, "<blockquote>quote</blockquote>");
});

test("sanitizeBlockquote unwraps unknown tags but keeps their text", () => {
const html = renderedHTML(
sanitizeBlockquote("<div><blockquote>quote</blockquote><h1>heading</h1></div>")
);
assert.equal(html, "<blockquote>quote</blockquote>heading");
});

test("sanitizeBlockquote drops img-based XSS entirely", () => {
const html = renderedHTML(
sanitizeBlockquote('<blockquote>q<img src=x onerror="alert(1)"></blockquote>')
);
assert.equal(html, "<blockquote>q</blockquote>");
});

test("sanitizeBlockquote keeps links with valid href, drops javascript: href", () => {
const good = renderedHTML(
sanitizeBlockquote('<blockquote><a href="https://example.com/">link</a></blockquote>')
);
assert.equal(good, '<blockquote><a href="https://example.com/" target="_blank">link</a></blockquote>');

const bad = renderedHTML(
sanitizeBlockquote('<blockquote><a href="javascript:alert(1)">link</a></blockquote>')
);
assert.equal(bad, "<blockquote><a>link</a></blockquote>");
});