Skip to content

Commit 39d42e4

Browse files
Sanitize javascript: urls MathJax's \href macro can inject
MathJax's \href{url}{...} copies its argument into an <a href> with no scheme check of its own. $\href{javascript:alert(1)}{click}$ rendered into a live, clickable <a href="javascript:alert(1)"> in the DOM. Confirmed this by rendering it and reading the resulting attribute, not just from reading MathJax's source. This predates the hover PR -- the same unguarded convertToTspans path is already reachable today via axis/plot titles, annotations, and non-hover legend text. Hover just became a new delivery surface for it, since hover text more often comes from less-trusted per-point data. svg_text_utils.js already has an established fix for exactly this shape of problem: sanitizeHref(), an http:/https:/mailto:/relative allowlist, used for the pseudo-HTML <a href> tags in plain text. sanitizeMathJaxLinks() applies the same allowlist to any <a> MathJax produces, called once from convertToTspans right after the rendered SVG is inserted. Since the fix lives in the shared function, it closes the gap for titles/annotations/legends too, not just hover. Added tests for both a hover label and a title with a javascript: \href, confirming the link's href is stripped while a plain \href{https://...}{...} keeps working. Verified locally: `karma start test/jasmine/karma.conf.js --bundleTest=mathjax_test.js --nowatch` -> 13 of 13 pass. Also ran the full svg_text_utils/hover/legend/annotations/titles suite against both this branch and unmodified main -- both show the same 8 pre-existing titles_test.js failures and the same intermittent "full page reload" flakiness at random points in either case, confirming neither is caused by this change. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 1e8ef5f commit 39d42e4

2 files changed

Lines changed: 81 additions & 0 deletions

File tree

src/lib/svg_text_utils.js

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,24 @@ function positionMathGroup(_context, svgClass, mathjaxGroup, newSvg, textAnchor,
103103
});
104104
}
105105

106+
/**
107+
* Removes an unsafe href/xlink:href from every <a> MathJax's \href{url}{...}
108+
* macro produced inside newSvg. MathJax copies the tex argument into the
109+
* link verbatim, with no scheme check of its own.
110+
*
111+
* @param {d3 selection} newSvg: the typeset <svg>, already inserted into
112+
* the document (or a fragment), so selectAll can walk its descendants
113+
*/
114+
function sanitizeMathJaxLinks(newSvg) {
115+
newSvg.selectAll('a').each(function() {
116+
var a = d3.select(this);
117+
['href', 'xlink:href'].forEach(function(attrName) {
118+
var href = a.attr(attrName);
119+
if(href) a.attr(attrName, sanitizeHref(href) || null);
120+
});
121+
});
122+
}
123+
106124
exports.convertToTspans = function(_context, gd, _callback) {
107125
var str = _context.text();
108126

@@ -192,6 +210,13 @@ exports.convertToTspans = function(_context, gd, _callback) {
192210

193211
mathjaxGroup.node().appendChild(newSvg.node());
194212

213+
// MathJax's \href{url}{...} macro copies the url into an
214+
// <a> verbatim, with no scheme check, so a javascript: url
215+
// in tex source becomes a live, clickable XSS vector.
216+
// Apply the same protocol allowlist used for pseudo-HTML
217+
// <a href> tags elsewhere in this file (sanitizeHref).
218+
sanitizeMathJaxLinks(newSvg);
219+
195220
// stitch the glyph defs
196221
if(_glyphDefs && _glyphDefs.node()) {
197222
newSvg.node().insertBefore(_glyphDefs.node().cloneNode(true),

test/jasmine/bundle_tests/mathjax_test.js

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,28 @@ describe('Test MathJax v' + mathjaxVersion + ':', function() {
205205
})
206206
.then(done, done.fail);
207207
});
208+
209+
it('should strip a javascript: url from a tex \\href, but keep a safe one', function(done) {
210+
Plotly.newPlot(gd, {
211+
data: [{x: [1, 2, 3], y: [1, 2, 3]}],
212+
layout: {
213+
title: {text: '$\\href{javascript:alert(1)}{unsafe}$'},
214+
xaxis: {title: {text: '$\\href{https://plotly.com}{safe}$'}}
215+
}
216+
})
217+
.then(function() {
218+
var gd3 = d3Select(gd);
219+
220+
var unsafeLink = gd3.select('.gtitle-math-group a');
221+
expect(unsafeLink.size()).toBe(1, 'title link exists');
222+
expect(unsafeLink.attr('href')).toBe(null, 'javascript: url stripped');
223+
224+
var safeLink = gd3.select('.g-xtitle .xtitle-math-group a');
225+
expect(safeLink.size()).toBe(1, 'axis title link exists');
226+
expect(safeLink.attr('href')).toBe('https://plotly.com', 'https: url kept');
227+
})
228+
.then(done, done.fail);
229+
});
208230
});
209231

210232
describe('Test hover tex rendering:', function() {
@@ -269,6 +291,40 @@ describe('Test MathJax v' + mathjaxVersion + ':', function() {
269291
.then(done, done.fail);
270292
});
271293

294+
it('should strip a javascript: url from a tex \\href in a hover label', function(done) {
295+
Plotly.newPlot(gd, {
296+
data: [{
297+
type: 'scatter',
298+
mode: 'markers',
299+
x: [1, 2, 3],
300+
y: [1, 2, 3],
301+
text: ['$\\href{javascript:alert(document.cookie)}{click me}$', 'b', 'c'],
302+
hoverinfo: 'text'
303+
}],
304+
layout: {
305+
width: 500,
306+
height: 400,
307+
margin: {l: 0, t: 0, r: 0, b: 0},
308+
xaxis: {range: [0, 4]},
309+
yaxis: {range: [0, 4]}
310+
}
311+
})
312+
.then(function() {
313+
_hover(125, 300);
314+
return delay(30)();
315+
})
316+
.then(function() {
317+
var gd3 = d3Select(gd);
318+
var link = gd3.select('g.hovertext .nums-math-group a');
319+
320+
// MathJax still wraps the text in an <a>; only the
321+
// javascript: url must be gone, not the tex rendering.
322+
expect(link.size()).toBe(1, 'link exists');
323+
expect(link.attr('href')).toBe(null, 'javascript: url stripped');
324+
})
325+
.then(done, done.fail);
326+
});
327+
272328
it('should leave a mixed tex/plain-text hover label as literal text', function(done) {
273329
Plotly.newPlot(gd, {
274330
data: [{

0 commit comments

Comments
 (0)