Skip to content

Commit aa9fc00

Browse files
committed
fix #1674 - set cursor:pointer on svg text links
also makes our handling of style and href more flexible and robust
1 parent b5cfd69 commit aa9fc00

File tree

2 files changed

+105
-64
lines changed

2 files changed

+105
-64
lines changed

src/lib/svg_text_utils.js

Lines changed: 78 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -221,19 +221,25 @@ function texToSVG(_texString, _config, _callback) {
221221
}
222222

223223
var TAG_STYLES = {
224-
// would like to use baseline-shift but FF doesn't support it yet
224+
// would like to use baseline-shift for sub/sup but FF doesn't support it
225225
// so we need to use dy along with the uber hacky shift-back-to
226226
// baseline below
227227
sup: 'font-size:70%" dy="-0.6em',
228228
sub: 'font-size:70%" dy="0.3em',
229229
b: 'font-weight:bold',
230230
i: 'font-style:italic',
231-
a: '',
231+
a: 'cursor:pointer',
232232
span: '',
233233
br: '',
234234
em: 'font-style:italic;font-weight:bold'
235235
};
236236

237+
// sub/sup: extra tspan with zero-width space to get back to the right baseline
238+
var TAG_CLOSE = {
239+
sup: '<tspan dy="0.42em">&#x200b;</tspan>',
240+
sub: '<tspan dy="-0.21em">&#x200b;</tspan>'
241+
};
242+
237243
var PROTOCOLS = ['http:', 'https:', 'mailto:'];
238244

239245
var STRIP_TAGS = new RegExp('</?(' + Object.keys(TAG_STYLES).join('|') + ')( [^>]*)?/?>', 'g');
@@ -254,6 +260,16 @@ var UNICODE_TO_ENTITY = Object.keys(stringMappings.unicodeToEntity).map(function
254260

255261
var NEWLINES = /(\r\n?|\n)/g;
256262

263+
var SPLIT_TAGS = /(<[^<>]*>)/;
264+
265+
var ONE_TAG = /<(\/?)([^ >]*)(\s+(.*))?>/i;
266+
267+
var QUOTEDATTR = '\\s*=\\s*("([^"]*)"|\'([^\']*)\')';
268+
var STYLEMATCH = new RegExp('(^|[\\s"\'])style' + QUOTEDATTR, 'i');
269+
var HREFMATCH = new RegExp('(^|[\\s"\'])href' + QUOTEDATTR, 'i');
270+
271+
var COLORMATCH = /(^|;)\s*color:/;
272+
257273
exports.plainText = function(_str) {
258274
// strip out our pseudo-html so we have a readable
259275
// version to put into text fields
@@ -280,84 +296,86 @@ function encodeForHTML(_str) {
280296
}
281297

282298
function convertToSVG(_str) {
283-
_str = convertEntities(_str);
284-
285-
// normalize behavior between IE and others wrt newlines and whitespace:pre
286-
// this combination makes IE barf https://github.com/plotly/plotly.js/issues/746
287-
// Chrome and FF display \n, \r, or \r\n as a space in this mode.
288-
// I feel like at some point we turned these into <br> but currently we don't so
289-
// I'm just going to cement what we do now in Chrome and FF
290-
_str = _str.replace(NEWLINES, ' ');
299+
_str = convertEntities(_str)
300+
/*
301+
* Normalize behavior between IE and others wrt newlines and whitespace:pre
302+
* this combination makes IE barf https://github.com/plotly/plotly.js/issues/746
303+
* Chrome and FF display \n, \r, or \r\n as a space in this mode.
304+
* I feel like at some point we turned these into <br> but currently we don't so
305+
* I'm just going to cement what we do now in Chrome and FF
306+
*/
307+
.replace(NEWLINES, ' ');
291308

292309
var result = _str
293-
.split(/(<[^<>]*>)/).map(function(d) {
294-
var match = d.match(/<(\/?)([^ >]*)\s*(.*)>/i),
295-
tag = match && match[2].toLowerCase(),
296-
style = TAG_STYLES[tag];
297-
298-
if(style !== undefined) {
299-
var close = match[1],
300-
extra = match[3],
301-
/**
302-
* extraStyle: any random extra css (that's supported by svg)
303-
* use this like <span style="font-family:Arial"> to change font in the middle
304-
*
305-
* at one point we supported <font family="..." size="..."> but as this isn't even
306-
* valid HTML anymore and we dropped it accidentally for many months, we will not
307-
* resurrect it.
308-
*/
309-
extraStyle = extra.match(/^style\s*=\s*"([^"]+)"\s*/i);
310-
311-
// anchor and br are the only ones that don't turn into a tspan
310+
.split(SPLIT_TAGS).map(function(d) {
311+
var match = d.match(ONE_TAG);
312+
var tag = match && match[2].toLowerCase();
313+
var tagStyle = TAG_STYLES[tag];
314+
315+
if(tagStyle !== undefined) {
316+
var isClose = match[1];
317+
if(isClose) return (tag === 'a' ? '</a>' : '</tspan>') + (TAG_CLOSE[tag] || '');
318+
319+
// break: later we'll turn these into newline <tspan>s
320+
// but we need to know about all the other tags first
321+
if(tag === 'br') return '<br>';
322+
323+
/**
324+
* extra includes href and any random extra css (that's supported by svg)
325+
* use this like <span style="font-family:Arial"> to change font in the middle
326+
*
327+
* at one point we supported <font family="..." size="..."> but as this isn't even
328+
* valid HTML anymore and we dropped it accidentally for many months, we will not
329+
* resurrect it.
330+
*/
331+
var extra = match[4];
332+
333+
var out;
334+
335+
// anchor is the only tag that doesn't turn into a tspan
312336
if(tag === 'a') {
313-
if(close) return '</a>';
314-
else if(extra.substr(0, 4).toLowerCase() !== 'href') return '<a>';
315-
else {
316-
// remove quotes, leading '=', replace '&' with '&amp;'
317-
var href = extra.substr(4)
318-
.replace(/["']/g, '')
319-
.replace(/=/, '');
320-
321-
// check protocol
337+
var hrefMatch = extra && extra.match(HREFMATCH);
338+
var href = hrefMatch && (hrefMatch[3] || hrefMatch[4]);
339+
340+
out = '<a';
341+
342+
if(href) {
343+
// check safe protocols
322344
var dummyAnchor = document.createElement('a');
323345
dummyAnchor.href = href;
324-
if(PROTOCOLS.indexOf(dummyAnchor.protocol) === -1) return '<a>';
325-
326-
return '<a xlink:show="new" xlink:href="' + encodeForHTML(href) + '">';
346+
if(PROTOCOLS.indexOf(dummyAnchor.protocol) !== -1) {
347+
out += ' xlink:show="new" xlink:href="' + encodeForHTML(href) + '"';
348+
}
327349
}
328350
}
329-
else if(tag === 'br') return '<br>';
330-
else if(close) {
331-
// closing tag
332-
333-
// sub/sup: extra tspan with zero-width space to get back to the right baseline
334-
if(tag === 'sup') return '</tspan><tspan dy="0.42em">&#x200b;</tspan>';
335-
if(tag === 'sub') return '</tspan><tspan dy="-0.21em">&#x200b;</tspan>';
336-
else return '</tspan>';
337-
}
338351
else {
339-
var tspanStart = '<tspan';
352+
out = '<tspan';
340353

341354
if(tag === 'sup' || tag === 'sub') {
342355
// sub/sup: extra zero-width space, fixes problem if new line starts with sub/sup
343-
tspanStart = '&#x200b;' + tspanStart;
356+
out = '&#x200b;' + out;
344357
}
358+
}
345359

346-
if(extraStyle) {
347-
// most of the svg css users will care about is just like html,
348-
// but font color is different. Let our users ignore this.
349-
extraStyle = extraStyle[1].replace(/(^|;)\s*color:/, '$1 fill:');
350-
style = encodeForHTML(extraStyle) + (style ? ';' + style : '');
351-
}
360+
// now add style, from both the tag name and any extra css
361+
// Most of the svg css that users will care about is just like html,
362+
// but font color is different (uses fill). Let our users ignore this.
363+
var cssMatch = extra && extra.match(STYLEMATCH);
364+
var css = cssMatch && (cssMatch[3] || cssMatch[4]);
365+
if(css) css = encodeForHTML(css.replace(COLORMATCH, '$1 fill:'));
352366

353-
return tspanStart + (style ? ' style="' + style + '"' : '') + '>';
354-
}
367+
if(tagStyle) css = tagStyle + ';' + (css || '');
368+
369+
if(css) return out + ' style="' + css + '">';
370+
371+
return out + '>';
355372
}
356373
else {
357374
return exports.xml_entity_encode(d).replace(/</g, '&lt;');
358375
}
359376
});
360377

378+
// now deal with line breaks
361379
var indices = [];
362380
for(var index = result.indexOf('<br>'); index > 0; index = result.indexOf('<br>', index + 1)) {
363381
indices.push(index);

test/jasmine/tests/svg_text_utils_test.js

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ describe('svg+text utils', function() {
3030
expect(tspan.attr('style')).toBe(style);
3131
}
3232

33-
function assertAnchorAttrs(node) {
33+
function assertAnchorAttrs(node, style) {
3434
var a = node.select('a');
3535

3636
var WHITE_LIST = ['xlink:href', 'xlink:show', 'style'],
@@ -44,6 +44,8 @@ describe('svg+text utils', function() {
4444
});
4545

4646
expect(hasWrongAttr).toBe(false);
47+
48+
expect(a.attr('style')).toBe('cursor:pointer;' + (style || ''));
4749
}
4850

4951
function listAttributes(node) {
@@ -120,7 +122,8 @@ describe('svg+text utils', function() {
120122
assertAnchorLink(node, 'mailto:support@plot.ly');
121123
});
122124

123-
it('wraps XSS attacks in href', function() {
125+
it('drops XSS attacks in href', function() {
126+
// "XSS" gets interpreted as a relative link (http)
124127
var textCases = [
125128
'<a href="XSS\" onmouseover="alert(1)\" style="font-size:300px">Subtitle</a>',
126129
'<a href="XSS" onmouseover="alert(1)" style="font-size:300px">Subtitle</a>'
@@ -130,8 +133,28 @@ describe('svg+text utils', function() {
130133
var node = mockTextSVGElement(textCase);
131134

132135
expect(node.text()).toEqual('Subtitle');
133-
assertAnchorAttrs(node);
134-
assertAnchorLink(node, 'XSS onmouseover=alert(1) style=font-size:300px');
136+
assertAnchorAttrs(node, 'font-size:300px');
137+
assertAnchorLink(node, 'XSS');
138+
});
139+
});
140+
141+
it('accepts href and style in <a> in any order and tosses other stuff', function() {
142+
var textCases = [
143+
'<a href="x" style="y">z</a>',
144+
'<a href=\'x\' style="y">z</a>',
145+
'<A HREF="x"StYlE=\'y\'>z</a>',
146+
'<a style=\'y\'href=\'x\'>z</A>',
147+
'<a \t\r\n href="x" \n\r\t style="y" \n \t \r>z</a>',
148+
'<a magic="true" href="x" weather="cloudy" style="y" speed="42">z</a>',
149+
'<a href="x" style="y">z</a href="nope" style="for real?">',
150+
];
151+
152+
textCases.forEach(function(textCase) {
153+
var node = mockTextSVGElement(textCase);
154+
155+
expect(node.text()).toEqual('z');
156+
assertAnchorAttrs(node, 'y');
157+
assertAnchorLink(node, 'x');
135158
});
136159
});
137160

0 commit comments

Comments
 (0)