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');
+ });
+});