diff --git a/src/lib/svg_text_utils.js b/src/lib/svg_text_utils.js index e38bc26acb6..93194a14145 100644 --- a/src/lib/svg_text_utils.js +++ b/src/lib/svg_text_utils.js @@ -625,6 +625,69 @@ function buildSVGText(containerNode, str) { return hasLink; } +/* + * sanitizeHTML: port of buildSVGText aimed at providing a clean subset of HTML + * @param {string} str: the html string to clean + * @returns {string}: a cleaned and normalized version of the input, + * supporting only a small subset of html + */ +exports.sanitizeHTML = function sanitizeHTML(str) { + str = str.replace(NEWLINES, ' '); + + var rootNode = document.createElement('p'); + var currentNode = rootNode; + var nodeStack = []; + + var parts = str.split(SPLIT_TAGS); + for(var i = 0; i < parts.length; i++) { + var parti = parts[i]; + var match = parti.match(ONE_TAG); + var tagType = match && match[2].toLowerCase(); + + if(tagType in TAG_STYLES) { + if(match[1]) { + if(nodeStack.length) { + currentNode = nodeStack.pop(); + } + } else { + var extra = match[4]; + + var css = getQuotedMatch(extra, STYLEMATCH); + var nodeAttrs = css ? {style: css} : {}; + + if(tagType === 'a') { + var href = getQuotedMatch(extra, HREFMATCH); + + if(href) { + var dummyAnchor = document.createElement('a'); + dummyAnchor.href = href; + if(PROTOCOLS.indexOf(dummyAnchor.protocol) !== -1) { + nodeAttrs.href = encodeURI(decodeURI(href)); + var target = getQuotedMatch(extra, TARGETMATCH); + if(target) { + nodeAttrs.target = target; + } + } + } + } + + var newNode = document.createElement(tagType); + currentNode.appendChild(newNode); + d3.select(newNode).attr(nodeAttrs); + + currentNode = newNode; + nodeStack.push(newNode); + } + } else { + currentNode.appendChild( + document.createTextNode(convertEntities(parti)) + ); + } + } + var key = 'innerHTML'; // i.e. to avoid pass test-syntax + return rootNode[key]; +}; + exports.lineCount = function lineCount(s) { return s.selectAll('tspan.line').size() || 1; }; diff --git a/src/plots/mapbox/layers.js b/src/plots/mapbox/layers.js index 6b13d457840..dd05b225667 100644 --- a/src/plots/mapbox/layers.js +++ b/src/plots/mapbox/layers.js @@ -9,6 +9,7 @@ 'use strict'; var Lib = require('../../lib'); +var sanitizeHTML = require('../../lib/svg_text_utils').sanitizeHTML; var convertTextOpts = require('./convert_text_opts'); var constants = require('./constants'); @@ -278,7 +279,9 @@ function convertSourceOpts(opts) { sourceOpts[field] = source; - if(opts.sourceattribution) sourceOpts.attribution = opts.sourceattribution; + if(opts.sourceattribution) { + sourceOpts.attribution = sanitizeHTML(opts.sourceattribution); + } return sourceOpts; } diff --git a/test/jasmine/tests/mapbox_test.js b/test/jasmine/tests/mapbox_test.js index 4407cdbca2c..538dd39f701 100644 --- a/test/jasmine/tests/mapbox_test.js +++ b/test/jasmine/tests/mapbox_test.js @@ -1536,15 +1536,18 @@ describe('@noCI, mapbox plots', function() { var mock = require('@mocks/mapbox_layers.json'); var customMock = Lib.extendDeep(mock); - var attr = 'super custom attribution'; + var attr = 'custom attribution'; + var XSS = ''; customMock.data.pop(); - customMock.layout.mapbox.layers[0].sourceattribution = attr; + customMock.layout.mapbox.layers[0].sourceattribution = XSS + attr; Plotly.newPlot(gd, customMock) .then(function() { var s = Plotly.d3.selectAll('.mapboxgl-ctrl-attrib'); expect(s.size()).toBe(1); - expect(s.text()).toEqual([attr, '© Mapbox © OpenStreetMap Improve this map'].join(' | ')); + expect(s.text()).toEqual([XSS + attr, '© Mapbox © OpenStreetMap Improve this map'].join(' | ')); + expect(s.html().indexOf('')).toBe(-1); + expect(s.html().indexOf('<img src=x onerror="alert(XSS);">')).not.toBe(-1); }) .catch(failTest) .then(done); diff --git a/test/jasmine/tests/svg_text_utils_test.js b/test/jasmine/tests/svg_text_utils_test.js index 461cb8cd8e6..5b1ed7b0260 100644 --- a/test/jasmine/tests/svg_text_utils_test.js +++ b/test/jasmine/tests/svg_text_utils_test.js @@ -117,12 +117,12 @@ describe('svg+text utils', function() { it('whitelists http hrefs', function() { var node = mockTextSVGElement( - 'bl.ocks.org' + 'bl.ocks.org' ); expect(node.text()).toEqual('bl.ocks.org'); assertAnchorAttrs(node); - assertAnchorLink(node, 'https://bl.ocks.org/'); + assertAnchorLink(node, 'http://bl.ocks.org/'); }); it('whitelists https hrefs', function() { @@ -512,3 +512,113 @@ describe('svg+text utils', function() { }); }); }); + +describe('sanitizeHTML', function() { + 'use strict'; + + var stringFromCodePoint; + + beforeAll(function() { + stringFromCodePoint = String.fromCodePoint; + }); + + afterEach(function() { + String.fromCodePoint = stringFromCodePoint; + }); + + function mockHTML(txt) { + return util.sanitizeHTML(txt); + } + + afterEach(function() { + d3.selectAll('.text-tester').remove(); + }); + + it('checks for XSS attack in href', function() { + var innerHTML = mockHTML( + 'XSS' + ); + + expect(innerHTML).toEqual('XSS'); + }); + + it('checks for XSS attack in href (with plenty of white spaces)', function() { + var innerHTML = mockHTML( + 'XSS' + ); + + expect(innerHTML).toEqual('XSS'); + }); + + it('whitelists relative hrefs (interpreted as http)', function() { + var innerHTML = mockHTML( + 'mylink' + ); + + expect(innerHTML).toEqual('mylink'); + }); + + it('whitelists http hrefs', function() { + var innerHTML = mockHTML( + 'bl.ocks.org' + ); + + expect(innerHTML).toEqual('bl.ocks.org'); + }); + + it('whitelists https hrefs', function() { + var innerHTML = mockHTML( + 'plotly' + ); + + expect(innerHTML).toEqual('plotly'); + }); + + it('whitelists mailto hrefs', function() { + var innerHTML = mockHTML( + 'support' + ); + + expect(innerHTML).toEqual('support'); + }); + + it('drops XSS attacks in href', function() { + // "XSS" gets interpreted as a relative link (http) + var textCases = [ + 'Subtitle', + 'Subtitle' + ]; + + textCases.forEach(function(textCase) { + var innerHTML = mockHTML(textCase); + + expect(innerHTML).toEqual('Subtitle'); + }); + }); + + it('accepts href and style in in any order and tosses other stuff', function() { + var textCases = [ + 'z', + 'z', + 'z', + 'z', + 'z', + 'z', + 'z', + ]; + + textCases.forEach(function(textCase) { + var innerHTML = mockHTML(textCase); + + expect(innerHTML).toEqual('z'); + }); + }); + + it('allows encoded URIs in href', function() { + var innerHTML = mockHTML( + 'click' + ); + + expect(innerHTML).toEqual('click'); + }); +});