Skip to content

Commit 662f51a

Browse files
authored
Merge pull request #4793 from plotly/sanitizeHTML
Sanitize sourceattribution in mapbox layers
2 parents b83c5cb + ef3492f commit 662f51a

File tree

4 files changed

+185
-6
lines changed

4 files changed

+185
-6
lines changed

src/lib/svg_text_utils.js

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -625,6 +625,69 @@ function buildSVGText(containerNode, str) {
625625
return hasLink;
626626
}
627627

628+
/*
629+
* sanitizeHTML: port of buildSVGText aimed at providing a clean subset of HTML
630+
* @param {string} str: the html string to clean
631+
* @returns {string}: a cleaned and normalized version of the input,
632+
* supporting only a small subset of html
633+
*/
634+
exports.sanitizeHTML = function sanitizeHTML(str) {
635+
str = str.replace(NEWLINES, ' ');
636+
637+
var rootNode = document.createElement('p');
638+
var currentNode = rootNode;
639+
var nodeStack = [];
640+
641+
var parts = str.split(SPLIT_TAGS);
642+
for(var i = 0; i < parts.length; i++) {
643+
var parti = parts[i];
644+
var match = parti.match(ONE_TAG);
645+
var tagType = match && match[2].toLowerCase();
646+
647+
if(tagType in TAG_STYLES) {
648+
if(match[1]) {
649+
if(nodeStack.length) {
650+
currentNode = nodeStack.pop();
651+
}
652+
} else {
653+
var extra = match[4];
654+
655+
var css = getQuotedMatch(extra, STYLEMATCH);
656+
var nodeAttrs = css ? {style: css} : {};
657+
658+
if(tagType === 'a') {
659+
var href = getQuotedMatch(extra, HREFMATCH);
660+
661+
if(href) {
662+
var dummyAnchor = document.createElement('a');
663+
dummyAnchor.href = href;
664+
if(PROTOCOLS.indexOf(dummyAnchor.protocol) !== -1) {
665+
nodeAttrs.href = encodeURI(decodeURI(href));
666+
var target = getQuotedMatch(extra, TARGETMATCH);
667+
if(target) {
668+
nodeAttrs.target = target;
669+
}
670+
}
671+
}
672+
}
673+
674+
var newNode = document.createElement(tagType);
675+
currentNode.appendChild(newNode);
676+
d3.select(newNode).attr(nodeAttrs);
677+
678+
currentNode = newNode;
679+
nodeStack.push(newNode);
680+
}
681+
} else {
682+
currentNode.appendChild(
683+
document.createTextNode(convertEntities(parti))
684+
);
685+
}
686+
}
687+
var key = 'innerHTML'; // i.e. to avoid pass test-syntax
688+
return rootNode[key];
689+
};
690+
628691
exports.lineCount = function lineCount(s) {
629692
return s.selectAll('tspan.line').size() || 1;
630693
};

src/plots/mapbox/layers.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
'use strict';
1010

1111
var Lib = require('../../lib');
12+
var sanitizeHTML = require('../../lib/svg_text_utils').sanitizeHTML;
1213
var convertTextOpts = require('./convert_text_opts');
1314
var constants = require('./constants');
1415

@@ -278,7 +279,9 @@ function convertSourceOpts(opts) {
278279

279280
sourceOpts[field] = source;
280281

281-
if(opts.sourceattribution) sourceOpts.attribution = opts.sourceattribution;
282+
if(opts.sourceattribution) {
283+
sourceOpts.attribution = sanitizeHTML(opts.sourceattribution);
284+
}
282285

283286
return sourceOpts;
284287
}

test/jasmine/tests/mapbox_test.js

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1536,15 +1536,18 @@ describe('@noCI, mapbox plots', function() {
15361536
var mock = require('@mocks/mapbox_layers.json');
15371537
var customMock = Lib.extendDeep(mock);
15381538

1539-
var attr = 'super custom attribution';
1539+
var attr = 'custom attribution';
1540+
var XSS = '<img src=x onerror=\"alert(XSS);\">';
15401541
customMock.data.pop();
1541-
customMock.layout.mapbox.layers[0].sourceattribution = attr;
1542+
customMock.layout.mapbox.layers[0].sourceattribution = XSS + attr;
15421543

15431544
Plotly.newPlot(gd, customMock)
15441545
.then(function() {
15451546
var s = Plotly.d3.selectAll('.mapboxgl-ctrl-attrib');
15461547
expect(s.size()).toBe(1);
1547-
expect(s.text()).toEqual([attr, '© Mapbox © OpenStreetMap Improve this map'].join(' | '));
1548+
expect(s.text()).toEqual([XSS + attr, '© Mapbox © OpenStreetMap Improve this map'].join(' | '));
1549+
expect(s.html().indexOf('<img src=x onerror="alert(XSS);">')).toBe(-1);
1550+
expect(s.html().indexOf('&lt;img src=x onerror="alert(XSS);"&gt;')).not.toBe(-1);
15481551
})
15491552
.catch(failTest)
15501553
.then(done);

test/jasmine/tests/svg_text_utils_test.js

Lines changed: 112 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,12 +117,12 @@ describe('svg+text utils', function() {
117117

118118
it('whitelists http hrefs', function() {
119119
var node = mockTextSVGElement(
120-
'<a href="https://bl.ocks.org/">bl.ocks.org</a>'
120+
'<a href="http://bl.ocks.org/">bl.ocks.org</a>'
121121
);
122122

123123
expect(node.text()).toEqual('bl.ocks.org');
124124
assertAnchorAttrs(node);
125-
assertAnchorLink(node, 'https://bl.ocks.org/');
125+
assertAnchorLink(node, 'http://bl.ocks.org/');
126126
});
127127

128128
it('whitelists https hrefs', function() {
@@ -512,3 +512,113 @@ describe('svg+text utils', function() {
512512
});
513513
});
514514
});
515+
516+
describe('sanitizeHTML', function() {
517+
'use strict';
518+
519+
var stringFromCodePoint;
520+
521+
beforeAll(function() {
522+
stringFromCodePoint = String.fromCodePoint;
523+
});
524+
525+
afterEach(function() {
526+
String.fromCodePoint = stringFromCodePoint;
527+
});
528+
529+
function mockHTML(txt) {
530+
return util.sanitizeHTML(txt);
531+
}
532+
533+
afterEach(function() {
534+
d3.selectAll('.text-tester').remove();
535+
});
536+
537+
it('checks for XSS attack in href', function() {
538+
var innerHTML = mockHTML(
539+
'<a href="javascript:alert(\'attack\')">XSS</a>'
540+
);
541+
542+
expect(innerHTML).toEqual('<a>XSS</a>');
543+
});
544+
545+
it('checks for XSS attack in href (with plenty of white spaces)', function() {
546+
var innerHTML = mockHTML(
547+
'<a href = " javascript:alert(\'attack\')">XSS</a>'
548+
);
549+
550+
expect(innerHTML).toEqual('<a>XSS</a>');
551+
});
552+
553+
it('whitelists relative hrefs (interpreted as http)', function() {
554+
var innerHTML = mockHTML(
555+
'<a href="/mylink">mylink</a>'
556+
);
557+
558+
expect(innerHTML).toEqual('<a href="/mylink">mylink</a>');
559+
});
560+
561+
it('whitelists http hrefs', function() {
562+
var innerHTML = mockHTML(
563+
'<a href="http://bl.ocks.org/">bl.ocks.org</a>'
564+
);
565+
566+
expect(innerHTML).toEqual('<a href="http://bl.ocks.org/">bl.ocks.org</a>');
567+
});
568+
569+
it('whitelists https hrefs', function() {
570+
var innerHTML = mockHTML(
571+
'<a href="https://chart-studio.plotly.com">plotly</a>'
572+
);
573+
574+
expect(innerHTML).toEqual('<a href="https://chart-studio.plotly.com">plotly</a>');
575+
});
576+
577+
it('whitelists mailto hrefs', function() {
578+
var innerHTML = mockHTML(
579+
'<a href="mailto:[email protected]">support</a>'
580+
);
581+
582+
expect(innerHTML).toEqual('<a href="mailto:[email protected]">support</a>');
583+
});
584+
585+
it('drops XSS attacks in href', function() {
586+
// "XSS" gets interpreted as a relative link (http)
587+
var textCases = [
588+
'<a href="XSS\" onmouseover="alert(1)\" style="font-size:300px">Subtitle</a>',
589+
'<a href="XSS" onmouseover="alert(1)" style="font-size:300px">Subtitle</a>'
590+
];
591+
592+
textCases.forEach(function(textCase) {
593+
var innerHTML = mockHTML(textCase);
594+
595+
expect(innerHTML).toEqual('<a style="font-size:300px" href="XSS">Subtitle</a>');
596+
});
597+
});
598+
599+
it('accepts href and style in <a> in any order and tosses other stuff', function() {
600+
var textCases = [
601+
'<a href="x" style="y">z</a>',
602+
'<a href=\'x\' style="y">z</a>',
603+
'<A HREF="x"StYlE=\'y\'>z</a>',
604+
'<a style=\'y\'href=\'x\'>z</A>',
605+
'<a \t\r\n href="x" \n\r\t style="y" \n \t \r>z</a>',
606+
'<a magic="true" href="x" weather="cloudy" style="y" speed="42">z</a>',
607+
'<a href="x" style="y">z</a href="nope" style="for real?">',
608+
];
609+
610+
textCases.forEach(function(textCase) {
611+
var innerHTML = mockHTML(textCase);
612+
613+
expect(innerHTML).toEqual('<a style="y" href="x">z</a>');
614+
});
615+
});
616+
617+
it('allows encoded URIs in href', function() {
618+
var innerHTML = mockHTML(
619+
'<a href="https://example.com/?q=date%20%3E=%202018-01-01">click</a>'
620+
);
621+
622+
expect(innerHTML).toEqual('<a href="https://example.com/?q=date%20%3E=%202018-01-01">click</a>');
623+
});
624+
});

0 commit comments

Comments
 (0)