diff --git a/README.md b/README.md index 2c44bb1..0336587 100644 --- a/README.md +++ b/README.md @@ -129,9 +129,39 @@ var renderer = new MobiledocDOMRenderer({ var rendered = renderer.render(mobiledoc); ``` +#### markupSanitizer + +Use this renderer option to customize how markup attribute values are sanitized. +The renderer's default markupSanitizer only sanitizes `href` values, prefixing +unsafe values with the string `"unsafe:"`. All other attribute values are +passed through unchanged. + +To change this behavior, pass your own markupSanitizer function when +instantiating the renderer. If your markupSanitizer function returns a string, +that value will be used when rendering. If it returns a falsy value, the +renderer's default markupSanitizer will be used. + +``` +var renderer = new MobiledocDOMRenderer({ + markupSanitizer: function({tagName, attributeName, attributeValue}) { + // This function will be called for every attribute on every markup. + // Return a sanitized attributeValue or undefined (in which case the + // default sanitizer will be used) + } +}); +``` + +The default sanitization of href values uses an environment-appropriate url +parser if it can find one. It's unlikely, but if the renderer is in an +environment where it cannot determine a url parser it will throw. (This can +happen when running the renderer in a VM Sandbox, like ember-cli-fastboot +does.) In this case you must supply a custom markupSanitizer that can handle +`href` sanitization. + ### Tests - * `npm test` + * To run tests via testem: `npm test` + * To run tests in the browser: `npm start` and open http://localhost:4200/tests ### Releasing diff --git a/lib/renderer-factory.js b/lib/renderer-factory.js index 8f5a02a..d8f0fd3 100644 --- a/lib/renderer-factory.js +++ b/lib/renderer-factory.js @@ -47,20 +47,18 @@ export default class RendererFactory { constructor({ - cards, - atoms, - cardOptions, + cards=[], + atoms=[], + cardOptions={}, unknownCardHandler, unknownAtomHandler, - markupElementRenderer, - sectionElementRenderer, - dom + markupElementRenderer={}, + sectionElementRenderer={}, + dom, + markupSanitizer=null }={}) { - cards = cards || []; validateCards(cards); - atoms = atoms || []; validateAtoms(atoms); - cardOptions = cardOptions || {}; if (!dom) { if (typeof window === 'undefined') { @@ -77,7 +75,8 @@ unknownAtomHandler, markupElementRenderer, sectionElementRenderer, - dom + dom, + markupSanitizer }; } diff --git a/lib/renderers/0-2.js b/lib/renderers/0-2.js index 19e8c15..5263589 100644 --- a/lib/renderers/0-2.js +++ b/lib/renderers/0-2.js @@ -9,30 +9,21 @@ import { } from '../utils/section-types'; import { isValidSectionTagName, - isMarkupSectionElementName, isValidMarkerType } from '../utils/tag-names'; import { - reduceAndSanitizeAttributes, - sanitizeAttributeValue + reduceAttributes } from '../utils/sanitization-utils'; +import { + createMarkupSanitizerWithFallback, + defaultSectionElementRenderer, + defaultMarkupElementRenderer +} from '../utils/render-utils'; export const MOBILEDOC_VERSION = '0.2.0'; const IMAGE_SECTION_TAG_NAME = 'img'; -function createElementFromMarkerType(dom, [tagName, attributes]=['', []]){ - let element = dom.createElement(tagName); - attributes = attributes || []; - - for (let i=0,l=attributes.length; i { + this.sectionElementRenderer[key.toLowerCase()] = sectionElementRenderer[key]; + }); - this.markupElementRenderer = {}; - if (markupElementRenderer) { - for (let key in markupElementRenderer) { - if (markupElementRenderer.hasOwnProperty(key)) { - this.markupElementRenderer[key.toLowerCase()] = markupElementRenderer[key]; - } - } - } + this.markupElementRenderer = { + '__default__': defaultMarkupElementRenderer + }; + Object.keys(markupElementRenderer).forEach(key => { + this.markupElementRenderer[key.toLowerCase()] = markupElementRenderer[key]; + }); this._renderCallbacks = []; this._teardownCallbacks = []; @@ -160,15 +149,7 @@ export default class Renderer { let markerType = this.markerTypes[openTypes[j]]; let [tagName, attrs=[]] = markerType; if (isValidMarkerType(tagName)) { - let lowerCaseTagName = tagName.toLowerCase(); - if (this.markupElementRenderer[lowerCaseTagName]) { - let attrObj = reduceAndSanitizeAttributes(attrs, lowerCaseTagName); - let openedElement = this.markupElementRenderer[lowerCaseTagName](tagName, this.dom, attrObj); - pushElement(openedElement); - } else { - let openedElement = createElementFromMarkerType(this.dom, markerType); - pushElement(openedElement); - } + pushElement(this.renderMarkupElement(tagName, attrs)); } else { closeCount--; } @@ -183,6 +164,37 @@ export default class Renderer { } } + /** + * @param attrs Array + */ + renderMarkupElement(tagName, attrs) { + tagName = tagName.toLowerCase(); + attrs = this.sanitizeAttributes(tagName, reduceAttributes(attrs)); + + let renderer = this.markupElementRendererFor(tagName); + return renderer(tagName, this.dom, attrs); + } + + markupElementRendererFor(tagName) { + return this.markupElementRenderer[tagName] || + this.markupElementRenderer.__default__; + } + + sanitizeAttributes(tagName, attrsObj) { + let sanitized = {}; + + Object.keys(attrsObj).forEach(attributeName => { + let attributeValue = attrsObj[attributeName]; + sanitized[attributeName] = this.sanitizeAttribute({tagName, attributeName, attributeValue}); + }); + + return sanitized; + } + + sanitizeAttribute({tagName, attributeName, attributeValue}) { + return this.markupSanitizer({tagName, attributeName, attributeValue}); + } + renderListItem(markers) { const element = this.dom.createElement('li'); this.renderMarkersOnElement(element, markers); @@ -270,23 +282,21 @@ export default class Renderer { } renderMarkupSection([type, tagName, markers]) { + tagName = tagName.toLowerCase(); if (!isValidSectionTagName(tagName, MARKUP_SECTION_TYPE)) { return; } - let element; - let lowerCaseTagName = tagName.toLowerCase(); - if (this.sectionElementRenderer[lowerCaseTagName]) { - element = this.sectionElementRenderer[lowerCaseTagName](tagName, this.dom); - } else if (isMarkupSectionElementName(tagName)) { - element = this.dom.createElement(tagName); - } else { - element = this.dom.createElement('div'); - element.setAttribute('class', tagName); - } + let renderer = this.sectionElementRendererFor(tagName); + let element = renderer(tagName, this.dom); this.renderMarkersOnElement(element, markers); return element; } + + sectionElementRendererFor(tagName) { + return this.sectionElementRenderer[tagName] || + this.sectionElementRenderer.__default__; + } } diff --git a/lib/renderers/0-3.js b/lib/renderers/0-3.js index c132cfc..d618c62 100644 --- a/lib/renderers/0-3.js +++ b/lib/renderers/0-3.js @@ -9,13 +9,16 @@ import { } from '../utils/section-types'; import { isValidSectionTagName, - isMarkupSectionElementName, isValidMarkerType } from '../utils/tag-names'; import { - reduceAndSanitizeAttributes, - sanitizeAttributeValue + reduceAttributes } from '../utils/sanitization-utils'; +import { + createMarkupSanitizerWithFallback, + defaultSectionElementRenderer, + defaultMarkupElementRenderer +} from '../utils/render-utils'; import { MARKUP_MARKER_TYPE, @@ -28,21 +31,13 @@ export const MOBILEDOC_VERSION = MOBILEDOC_VERSION_0_3_0; const IMAGE_SECTION_TAG_NAME = 'img'; -function createElementFromMarkerType(dom, [tagName, attributes]=['', []]){ - let element = dom.createElement(tagName); - attributes = attributes || []; - - for (let i=0,l=attributes.length; i { + this.sectionElementRenderer[key.toLowerCase()] = sectionElementRenderer[key]; + }); - this.markupElementRenderer = {}; - if (markupElementRenderer) { - for (let key in markupElementRenderer) { - if (markupElementRenderer.hasOwnProperty(key)) { - this.markupElementRenderer[key.toLowerCase()] = markupElementRenderer[key]; - } - } - } + this.markupElementRenderer = { + '__default__': defaultMarkupElementRenderer + }; + Object.keys(markupElementRenderer).forEach(key => { + this.markupElementRenderer[key.toLowerCase()] = markupElementRenderer[key]; + }); this._renderCallbacks = []; this._teardownCallbacks = []; @@ -174,16 +167,9 @@ export default class Renderer { for (let j=0, m=openTypes.length; j { + let attributeValue = attrsObj[attributeName]; + sanitized[attributeName] = this.sanitizeAttribute({tagName, attributeName, attributeValue}); + }); + + return sanitized; + } + + sanitizeAttribute({tagName, attributeName, attributeValue}) { + return this.markupSanitizer({tagName, attributeName, attributeValue}); + } + renderListItem(markers) { const element = this.dom.createElement('li'); this.renderMarkersOnElement(element, markers); @@ -376,23 +393,21 @@ export default class Renderer { } renderMarkupSection([type, tagName, markers]) { + tagName = tagName.toLowerCase(); if (!isValidSectionTagName(tagName, MARKUP_SECTION_TYPE)) { return; } - let element; - let lowerCaseTagName = tagName.toLowerCase(); - if (this.sectionElementRenderer[lowerCaseTagName]) { - element = this.sectionElementRenderer[lowerCaseTagName](tagName, this.dom); - } else if (isMarkupSectionElementName(tagName)) { - element = this.dom.createElement(tagName); - } else { - element = this.dom.createElement('div'); - element.setAttribute('class', tagName); - } + let renderer = this.sectionElementRendererFor(tagName); + let element = renderer(tagName, this.dom); this.renderMarkersOnElement(element, markers); return element; } + + sectionElementRendererFor(tagName) { + return this.sectionElementRenderer[tagName] || + this.sectionElementRenderer.__default__; + } } diff --git a/lib/utils/render-utils.js b/lib/utils/render-utils.js new file mode 100644 index 0000000..5773d36 --- /dev/null +++ b/lib/utils/render-utils.js @@ -0,0 +1,48 @@ +import { + isMarkupSectionElementName +} from '../utils/tag-names'; +import { + sanitizeHref +} from './sanitization-utils'; + +function defaultMarkupSanitizer({tagName, attributeName, attributeValue}) { + if (tagName === 'a' && attributeName === 'href') { + return sanitizeHref(attributeValue); + } else { + return attributeValue; + } +} + +/* + * return a sanitizer function that first uses the passed sanitizer + * (if present), and then uses the default sanitizer if that didn't return + * a string + */ +export function createMarkupSanitizerWithFallback(sanitizer) { + if (sanitizer) { + return (...args) => sanitizer(...args) || defaultMarkupSanitizer(...args); + } else { + return defaultMarkupSanitizer; + } +} + +export function defaultSectionElementRenderer(tagName, dom) { + let element; + if (isMarkupSectionElementName(tagName)) { + element = dom.createElement(tagName); + } else { + element = dom.createElement('div'); + element.setAttribute('class', tagName); + } + + return element; +} + +export function defaultMarkupElementRenderer(tagName, dom, attrsObj) { + let element = dom.createElement(tagName); + Object.keys(attrsObj).forEach(key => { + element.setAttribute(key, attrsObj[key]); + }); + return element; +} + diff --git a/lib/utils/sanitization-utils.js b/lib/utils/sanitization-utils.js index 1422bd2..5f4af8d 100644 --- a/lib/utils/sanitization-utils.js +++ b/lib/utils/sanitization-utils.js @@ -30,7 +30,7 @@ function getProtocol(url) { } } -function sanitizeHref(url) { +export function sanitizeHref(url) { let protocol = getProtocol(url); if (includes(badProtocols, protocol)) { return `unsafe:${url}`; @@ -38,20 +38,16 @@ function sanitizeHref(url) { return url; } -export function sanitizeAttributeValue(attributeName, attributeValue, tagName) { - if (tagName === 'a' && attributeName === 'href') { - return sanitizeHref(attributeValue); +/** + * @param attributes array + * @return obj with normalized attribute names (lowercased) + */ +export function reduceAttributes(attributes) { + let obj = {}; + for (let i = 0; i < attributes.length; i += 2) { + let key = attributes[i]; + let val = attributes[i+1]; + obj[key.toLowerCase()] = val; } - return attributeValue; + return obj; } - -export function reduceAndSanitizeAttributes(attributes, tagName) { - let attrsObj = {}; - for (let i=0,l=attributes.length; i { assert.equal(content, `

link textplain text

`); }); +test('renderer delegates to provided "markupSanitizer"', function(assert) { + let called = 0; + + let markupSanitizer = ({tagName, attributeName, attributeValue}) => { + called++; + return attributeValue + 'changed'; + }; + + let renderer = new Renderer({markupSanitizer}); + + let mobiledoc = { + version: MOBILEDOC_VERSION, + sections: [ + [ + ["a", [ "href", 'http://google.com/' ]] + ], + [ + [MARKUP_SECTION_TYPE, "p", [ + [[0], 1, "hello world"] + ]] + ] + ] + }; + let { result } = renderer.render(mobiledoc); + let content = outerHTML(result); + assert.equal(content, `

hello world

`); + assert.equal(called, 1, 'markupSanitizer called'); +}); + +test('when markupSanitizer returns nothing, default sanitizer is used', function(assert) { + let called = 0; + let unsafeHref = 'javascript:evil'; // jshint ignore:line + + let markupSanitizer = () => { + called++; + return; + }; + + let renderer = new Renderer({markupSanitizer}); + + let mobiledoc = { + version: MOBILEDOC_VERSION, + sections: [ + [ + ["a", [ "href", unsafeHref ]] + ], + [ + [MARKUP_SECTION_TYPE, "p", [ + [[0], 1, 'hello world'] + ]] + ] + ] + }; + let { result } = renderer.render(mobiledoc); + let content = outerHTML(result); + assert.equal(content, `

hello world

`); + assert.equal(called, 1, 'markupSanitizer called'); +}); + test('renders a mobiledoc with sectionElementRenderer', (assert) => { let mobiledoc = { version: MOBILEDOC_VERSION, @@ -628,7 +687,7 @@ test('renders a mobiledoc with markupElementRenderer', (assert) => { 'renders text inside of marker'); assert.equal(rendered.firstChild.childNodes[1].tagName, 'SPAN', 'transforms markup nodes'); - assert.propEqual(rendered.firstChild.childNodes[1].dataset, {tag: "A", href: "#foo"}, + assert.propEqual(rendered.firstChild.childNodes[1].dataset, {tag: "a", href: "#foo"}, 'passes original tag and attributes to transform'); assert.equal(rendered.firstChild.childNodes[0].textContent, 'Lorem ipsum ', 'renders plain text nodes'); diff --git a/tests/unit/renderers/0-3-test.js b/tests/unit/renderers/0-3-test.js index 704249d..52e392c 100644 --- a/tests/unit/renderers/0-3-test.js +++ b/tests/unit/renderers/0-3-test.js @@ -19,6 +19,12 @@ import { MARKUP_MARKER_TYPE, ATOM_MARKER_TYPE } from 'mobiledoc-dom-renderer/utils/marker-types'; +import { + createBlankMobiledoc, + createSimpleMobiledoc, + createMobiledocWithCard, + createMobiledocWithAtom +} from '../../helpers/create-mobiledoc'; const { test, module } = QUnit; const MOBILEDOC_VERSION_0_3_0 = '0.3.0'; @@ -33,31 +39,14 @@ let renderer; function generateTests() { test('renders an empty mobiledoc', (assert) => { - let mobiledoc = { - version: MOBILEDOC_VERSION_0_3_0, - atoms: [], - cards: [], - markups: [], - sections: [] - }; - let { result: rendered } = renderer.render(mobiledoc); + let { result: rendered } = renderer.render(createBlankMobiledoc()); assert.ok(!!rendered, 'renders result'); assert.equal(childNodesLength(rendered), 0, 'has no sections'); }); test('renders a mobiledoc without markups', (assert) => { - let mobiledoc = { - version: MOBILEDOC_VERSION_0_3_0, - atoms: [], - cards: [], - markups: [], - sections: [ - [MARKUP_SECTION_TYPE, 'P', [ - [MARKUP_MARKER_TYPE, [], 0, 'hello world']] - ] - ] - }; + let mobiledoc = createSimpleMobiledoc({text:'hello world'}); let renderResult = renderer.render(mobiledoc); let { result: rendered } = renderResult; assert.equal(childNodesLength(rendered), 1, @@ -69,17 +58,11 @@ test('renders a mobiledoc without markups', (assert) => { }); test('renders 0.3.0 markup section "pull-quote" as div with class', (assert) => { - let mobiledoc = { + let mobiledoc = createSimpleMobiledoc({ version: MOBILEDOC_VERSION_0_3_0, - atoms: [], - cards: [], - markups: [], - sections: [ - [MARKUP_SECTION_TYPE, 'pull-quote', [ - [MARKUP_MARKER_TYPE, [], 0, 'hello world']] - ] - ] - }; + sectionName: 'pull-quote', + text: 'hello world' + }); let { result: rendered } = renderer.render(mobiledoc); assert.equal(childNodesLength(rendered), 1, 'renders 1 section'); @@ -89,17 +72,11 @@ test('renders 0.3.0 markup section "pull-quote" as div with class', (assert) => }); test('renders 0.3.1 markup section "pull-quote" as div with class', (assert) => { - let mobiledoc = { + let mobiledoc = createSimpleMobiledoc({ version: MOBILEDOC_VERSION_0_3_1, - atoms: [], - cards: [], - markups: [], - sections: [ - [MARKUP_SECTION_TYPE, 'pull-quote', [ - [MARKUP_MARKER_TYPE, [], 0, 'hello world']] - ] - ] - }; + sectionName: 'pull-quote', + text: 'hello world' + }); let { result: rendered } = renderer.render(mobiledoc); assert.equal(childNodesLength(rendered), 1, 'renders 1 section'); @@ -109,17 +86,11 @@ test('renders 0.3.1 markup section "pull-quote" as div with class', (assert) => }); test('renders markup section "aside"', (assert) => { - let mobiledoc = { + let mobiledoc = createSimpleMobiledoc({ version: MOBILEDOC_VERSION_0_3_1, - atoms: [], - cards: [], - markups: [], - sections: [ - [MARKUP_SECTION_TYPE, 'aside', [ - [MARKUP_MARKER_TYPE, [], 0, 'hello world']] - ] - ] - }; + sectionName: 'aside', + text: 'hello world' + }); let { result: rendered } = renderer.render(mobiledoc); assert.equal(childNodesLength(rendered), 1, 'renders 1 section'); @@ -129,19 +100,12 @@ test('renders markup section "aside"', (assert) => { }); test('renders a mobiledoc with simple (no attributes) markup', (assert) => { - let mobiledoc = { - version: MOBILEDOC_VERSION_0_3_0, - atoms: [], - cards: [], - markups: [ - ['B'] - ], - sections: [ - [MARKUP_SECTION_TYPE, 'P', [ - [MARKUP_MARKER_TYPE, [0], 1, 'hello world']] - ] - ] - }; + let mobiledoc = createSimpleMobiledoc({ + version: MOBILEDOC_VERSION_0_3_1, + sectionName: 'aside', + text: 'hello world', + markup: ['B'] + }); let { result: rendered } = renderer.render(mobiledoc); assert.equal(childNodesLength(rendered), 1, 'renders 1 section'); @@ -151,19 +115,12 @@ test('renders a mobiledoc with simple (no attributes) markup', (assert) => { }); test('renders a mobiledoc with complex (has attributes) markup', (assert) => { - let mobiledoc = { - version: MOBILEDOC_VERSION_0_3_0, - atoms: [], - cards: [], - markups: [ - ['A', ['href', 'http://google.com']], - ], - sections: [ - [MARKUP_SECTION_TYPE, 'P', [ - [MARKUP_MARKER_TYPE, [0], 1, 'hello world'] - ]] - ] - }; + let mobiledoc = createSimpleMobiledoc({ + version: MOBILEDOC_VERSION_0_3_1, + sectionName: 'aside', + text: 'hello world', + markup: ['A', ['href', 'http://google.com']] + }); let { result: rendered } = renderer.render(mobiledoc); assert.equal(childNodesLength(rendered), 1, 'renders 1 section'); @@ -345,17 +302,10 @@ test('throws if card render returns invalid result', (assert) => { type: 'dom', render() { return 'string'; } }; - let mobiledoc = { + let mobiledoc = createMobiledocWithCard({ version: MOBILEDOC_VERSION_0_3_0, - atoms: [], - cards: [ - [card.name] - ], - markups: [], - sections: [ - [CARD_SECTION_TYPE, 0] - ] - }; + card: {name: card.name} + }); renderer = new Renderer({cards:[card]}); assert.throws( @@ -370,17 +320,10 @@ test('card may render nothing', (assert) => { type: 'dom', render() {} }; - let mobiledoc = { + let mobiledoc = createMobiledocWithCard({ version: MOBILEDOC_VERSION_0_3_0, - atoms: [], - cards: [ - [card.name] - ], - markups: [], - sections: [ - [CARD_SECTION_TYPE, 0] - ] - }; + card: {name: card.name} + }); renderer = new Renderer({cards:[card]}); renderer.render(mobiledoc); @@ -390,8 +333,9 @@ test('card may render nothing', (assert) => { test('rendering nested mobiledocs in cards', (assert) => { let renderer; + let cardName = 'nested-card'; let cards = [{ - name: 'nested-card', + name: cardName, type: 'dom', render({payload}) { let {result: rendered} = renderer.render(payload.mobiledoc); @@ -399,29 +343,15 @@ test('rendering nested mobiledocs in cards', (assert) => { } }]; - let innerMobiledoc = { + let innerMobiledoc = createSimpleMobiledoc({ version: MOBILEDOC_VERSION_0_3_0, - atoms: [], - cards: [], - markups: [], - sections: [ - [MARKUP_SECTION_TYPE, 'P', [ - [MARKUP_MARKER_TYPE, [], 0, 'hello world']] - ] - ] - }; + text: 'hello world' + }); - let mobiledoc = { + let mobiledoc = createMobiledocWithCard({ version: MOBILEDOC_VERSION_0_3_0, - atoms: [], - cards: [ - ['nested-card', {mobiledoc: innerMobiledoc}] - ], - markups: [], - sections: [ - [CARD_SECTION_TYPE, 0] - ] - }; + card: {name: cardName, payload: {mobiledoc: innerMobiledoc}} + }); renderer = new Renderer({cards}); let { result: rendered } = renderer.render(mobiledoc); @@ -434,17 +364,10 @@ test('rendering nested mobiledocs in cards', (assert) => { test('rendering unknown card without unknownCardHandler throws', (assert) => { let cardName = 'not-known'; - let mobiledoc = { + let mobiledoc = createMobiledocWithCard({ version: MOBILEDOC_VERSION_0_3_0, - atoms: [], - cards: [ - [cardName] - ], - markups: [], - sections: [ - [CARD_SECTION_TYPE, 0] - ] - }; + card: {name: cardName} + }); renderer = new Renderer({cards: [], unknownCardHandler: undefined}); assert.throws( () => renderer.render(mobiledoc), @@ -469,17 +392,10 @@ test('rendering unknown card uses unknownCardHandler', (assert) => { assert.deepEqual(payload, expectedPayload, 'correct payload'); }; - let mobiledoc = { + let mobiledoc = createMobiledocWithCard({ version: MOBILEDOC_VERSION_0_3_0, - atoms: [], - cards: [ - [cardName, expectedPayload] - ], - markups: [], - sections: [ - [CARD_SECTION_TYPE, 0] - ] - }; + card: {name: cardName, payload: expectedPayload} + }); renderer = new Renderer({ cards: [], cardOptions: expectedOptions, unknownCardHandler }); @@ -508,17 +424,10 @@ test('multiple spaces should preserve whitespace with nbsps', (assert) => { repeat(space, 5), 'text', repeat(space, 6) ].join(''); - let mobiledoc = { + let mobiledoc = createSimpleMobiledoc({ version: MOBILEDOC_VERSION_0_3_0, - atoms: [], - cards: [], - markups: [], - sections: [ - [MARKUP_SECTION_TYPE, 'P', [ - [MARKUP_MARKER_TYPE, [], 0, text]] - ] - ] - }; + text + }); let nbsp = '\u00A0'; let sn = space + nbsp; @@ -533,13 +442,7 @@ test('multiple spaces should preserve whitespace with nbsps', (assert) => { }); test('throws when given unexpected mobiledoc version', (assert) => { - let mobiledoc = { - version: '0.1.0', - atoms: [], - cards: [], - markups: [], - sections: [] - }; + let mobiledoc = createBlankMobiledoc({version: '0.1.0'}); assert.throws( () => renderer.render(mobiledoc), @@ -596,7 +499,7 @@ test('XSS: unexpected markup types are not rendered', (assert) => { assert.ok(content.indexOf('script') === -1, 'no script tag rendered'); }); -test('XSS: links with dangerous href values are not rendered', (assert) => { +test('XSS: links with dangerous href values are sanitized', (assert) => { let unsafeHref = 'javascript:alert("link XSS")'; // jshint ignore:line let mobiledoc = { version: MOBILEDOC_VERSION_0_3_0, @@ -622,6 +525,57 @@ test('XSS: links with dangerous href values are not rendered', (assert) => { assert.equal(content, `

link textplain text

`); }); +test('XSS: "a" markups are sanitized if upper or lower case', function(assert) { + let unsafeHref = 'javascript:alert("link XSS")'; // jshint ignore:line + let markups = [ + ['a', ['href', unsafeHref]], + ['A', ['href', unsafeHref]], + ['a', ['HREF', unsafeHref]] + ]; + + markups.forEach(markup => { + let mobiledoc = createSimpleMobiledoc({markup}); + let { result } = renderer.render(mobiledoc); + let content = outerHTML(result); + assert.equal(content, `

hello world

`); + }); +}); + +test('renderer delegates to provided "markupSanitizer"', function(assert) { + let called = 0; + + let markupSanitizer = ({tagName, attributeName, attributeValue}) => { + called++; + return attributeValue + 'changed'; + }; + + let renderer = new Renderer({markupSanitizer}); + + let mobiledoc = createSimpleMobiledoc({markup: ['a', ['href', 'http://google.com/']]}); + let { result } = renderer.render(mobiledoc); + let content = outerHTML(result); + assert.equal(content, `

hello world

`); + assert.equal(called, 1, 'markupSanitizer called'); +}); + +test('when markupSanitizer returns nothing, default sanitizer is used', function(assert) { + let called = 0; + let unsafeHref = 'javascript:evil'; // jshint ignore:line + + let markupSanitizer = () => { + called++; + return; + }; + + let renderer = new Renderer({markupSanitizer}); + + let mobiledoc = createSimpleMobiledoc({markup: ['a', ['href', unsafeHref]]}); + let { result } = renderer.render(mobiledoc); + let content = outerHTML(result); + assert.equal(content, `

hello world

`); + assert.equal(called, 1, 'markupSanitizer called'); +}); + test('renders a mobiledoc with atom', (assert) => { assert.expect(8); let atomName = 'hello-atom'; @@ -722,19 +676,10 @@ test('atom may render nothing', (assert) => { type: 'dom', render() {} }; - let mobiledoc = { + let mobiledoc = createMobiledocWithAtom({ version: MOBILEDOC_VERSION_0_3_0, - atoms: [ - ['ok', 'Bob', { id: 42 }], - ], - cards: [], - markups: [], - sections: [ - [MARKUP_SECTION_TYPE, 'P', [ - [ATOM_MARKER_TYPE, [], 0, 0]] - ] - ] - }; + atom: ['ok', 'Bob', { id: 42 }] + }); renderer = new Renderer({atoms:[atom]}); renderer.render(mobiledoc); @@ -743,19 +688,10 @@ test('atom may render nothing', (assert) => { }); test('throws when rendering unknown atom without unknownAtomHandler', (assert) => { - let mobiledoc = { + let mobiledoc = createMobiledocWithAtom({ version: MOBILEDOC_VERSION_0_3_0, - atoms: [ - ['missing-atom', 'Bob', { id: 42 }], - ], - cards: [], - markups: [], - sections: [ - [MARKUP_SECTION_TYPE, 'P', [ - [ATOM_MARKER_TYPE, [], 0, 0]] - ] - ] - }; + atom: ['missing-atom', 'Bob', { id: 42 }] + }); renderer = new Renderer({atoms: [], unknownAtomHandler: undefined}); assert.throws( () => renderer.render(mobiledoc), @@ -769,19 +705,10 @@ test('rendering unknown atom uses unknownAtomHandler', (assert) => { let atomName = 'missing-atom'; let expectedPayload = { id: 42 }; let cardOptions = {}; - let mobiledoc = { + let mobiledoc = createMobiledocWithAtom({ version: MOBILEDOC_VERSION_0_3_0, - atoms: [ - ['missing-atom', 'Bob', { id: 42 }], - ], - cards: [], - markups: [], - sections: [ - [MARKUP_SECTION_TYPE, 'P', [ - [ATOM_MARKER_TYPE, [], 0, 0]] - ] - ] - }; + atom: ['missing-atom', 'Bob', { id: 42 }] + }); let unknownAtomHandler = ({env, payload, options}) => { assert.equal(env.name, atomName, 'correct name'); assert.ok(!!env.onTeardown, 'onTeardown hook exists'); @@ -833,13 +760,13 @@ test('renders a mobiledoc with sectionElementRenderer', (assert) => { test('renders a mobiledoc with markupElementRenderer', (assert) => { let mobiledoc = { - "version": MOBILEDOC_VERSION_0_3_0, - "atoms": [], - "cards": [], - "markups": [ + version: MOBILEDOC_VERSION_0_3_0, + atoms: [], + cards: [], + markups: [ ["a", [ "href", "#foo" ]] ], - "sections": [ + sections: [ [MARKUP_SECTION_TYPE, "p", [ [MARKUP_MARKER_TYPE, [], 0, "Lorem ipsum "], [MARKUP_MARKER_TYPE, [0], 1, "dolor"], @@ -873,19 +800,11 @@ test('renders a mobiledoc with markupElementRenderer', (assert) => { }); test('unexpected markup types are not handled by markup renderer', (assert) => { - let mobiledoc = { + let mobiledoc = createSimpleMobiledoc({ version: MOBILEDOC_VERSION_0_3_0, - atoms: [], - cards: [], - markups: [ - ['script'] - ], - sections: [ - [MARKUP_SECTION_TYPE, 'p', [ - [MARKUP_MARKER_TYPE, [0], 1, 'alert("markup XSS")'] - ]] - ] - }; + markup: ['script'], + text: 'alert("markup XSS")' + }); renderer = new Renderer({ markupElementRenderer: { SCRIPT: (markerType, dom) => { @@ -908,17 +827,10 @@ module('Unit: Mobiledoc DOM Renderer - 0.3', { generateTests(); test('teardown removes rendered sections from dom', (assert) => { - let mobiledoc = { + let mobiledoc = createSimpleMobiledoc({ version: MOBILEDOC_VERSION_0_3_0, - atoms: [], - cards: [], - markups: [], - sections: [ - [MARKUP_SECTION_TYPE, 'p', [ - [MARKUP_MARKER_TYPE, [], 0, 'Hello world'] - ]] - ] - }; + text: 'Hello world' + }); let { result: rendered, teardown } = renderer.render(mobiledoc); assert.equal(childNodesLength(rendered), 1, 'renders 1 section'); @@ -945,17 +857,10 @@ test('teardown hook calls registered teardown methods', (assert) => { } }; - let mobiledoc = { + let mobiledoc = createMobiledocWithCard({ version: MOBILEDOC_VERSION_0_3_0, - atoms: [], - cards: [ - [cardName] - ], - markups: [], - sections: [ - [CARD_SECTION_TYPE, 0] - ] - }; + card: {name: cardName} + }); renderer = new Renderer({cards: [card]}); let { teardown } = renderer.render(mobiledoc); @@ -979,17 +884,10 @@ test('render hook calls registered didRender callbacks', (assert) => { } }; - let mobiledoc = { + let mobiledoc = createMobiledocWithCard({ version: MOBILEDOC_VERSION_0_3_0, - atoms: [], - cards: [ - [cardName] - ], - markups: [], - sections: [ - [CARD_SECTION_TYPE, 0] - ] - }; + card: { name: cardName } + }); renderer = new Renderer({cards: [card]}); diff --git a/tests/unit/utils/sanitization-utils-test.js b/tests/unit/utils/sanitization-utils-test.js index 93626e6..5133a79 100644 --- a/tests/unit/utils/sanitization-utils-test.js +++ b/tests/unit/utils/sanitization-utils-test.js @@ -1,15 +1,14 @@ /* global QUnit */ import { - sanitizeAttributeValue, - reduceAndSanitizeAttributes + sanitizeHref } from 'mobiledoc-dom-renderer/utils/sanitization-utils'; const { test, module } = QUnit; module('Unit: Mobiledoc DOM Renderer - Sanitization utils'); -test('#sanitizeAttributeValue - a', (assert) => { +test('#sanitizeHref', (assert) => { let unsafe = [ 'javascript:alert("XSS")', // jshint ignore: line 'vbscript:alert("XSS")' // jshint ignore: line @@ -17,7 +16,7 @@ test('#sanitizeAttributeValue - a', (assert) => { for (let i = 0; i < unsafe.length; i++) { let url = unsafe[i]; - assert.equal(sanitizeAttributeValue('href', url, 'a'), `unsafe:${url}`); + assert.equal(sanitizeHref(url), `unsafe:${url}`); } let safe = [ @@ -32,37 +31,6 @@ test('#sanitizeAttributeValue - a', (assert) => { for (let i = 0; i < safe.length; i++) { let url = safe[i]; - assert.equal(sanitizeAttributeValue('href', url, 'a'), url); - } -}); - -test('#reduceAndSanitizeAttributes - a', (assert) => { - let unsafe = [ - 'javascript:alert("XSS")', // jshint ignore: line - 'vbscript:alert("XSS")' // jshint ignore: line - ]; - - for (let i = 0; i < unsafe.length; i++) { - let url = unsafe[i]; - assert.deepEqual(reduceAndSanitizeAttributes(['href', url], 'a'), { - 'href': `unsafe:${url}` - }); - } - - let safe = [ - 'http://www.google.com', - 'https://www.google.com', - 'ftp://google.com', - 'http://www.google.com/with-path', - 'www.google.com', - 'tel:12345', - 'mailto:john@doe.com' - ]; - - for (let i = 0; i < safe.length; i++) { - let url = safe[i]; - assert.deepEqual(reduceAndSanitizeAttributes(['href', url], 'a'), { - 'href': url - }); + assert.equal(sanitizeHref(url), url); } });