diff --git a/draftlogs/7256_fix.md b/draftlogs/7256_fix.md
new file mode 100644
index 00000000000..36d1b45a874
--- /dev/null
+++ b/draftlogs/7256_fix.md
@@ -0,0 +1,2 @@
+ - Remove inline styles in SVG text elements generated from pseudo-HTML configurations, which break with strict CSP setups [[#7256](https://github.com/plotly/plotly.js/pull/7256)],
+ with thanks to @martian111 for the contribution!
diff --git a/src/lib/svg_text_utils.js b/src/lib/svg_text_utils.js
index 2c5ddc7f1d9..ff7396e5dd1 100644
--- a/src/lib/svg_text_utils.js
+++ b/src/lib/svg_text_utils.js
@@ -328,15 +328,15 @@ var TAG_STYLES = {
// would like to use baseline-shift for sub/sup but FF doesn't support it
// so we need to use dy along with the uber hacky shift-back-to
// baseline below
- sup: 'font-size:70%',
- sub: 'font-size:70%',
- s: 'text-decoration:line-through',
- u: 'text-decoration:underline',
- b: 'font-weight:bold',
- i: 'font-style:italic',
- a: 'cursor:pointer',
- span: '',
- em: 'font-style:italic;font-weight:bold'
+ sup: {'font-size':'70%'},
+ sub: {'font-size':'70%'},
+ s: {'text-decoration':'line-through'},
+ u: {'text-decoration':'underline'},
+ b: {'font-weight': 'bold'},
+ i: {'font-style':'italic'},
+ a: {'cursor':'pointer'},
+ span: {},
+ em: {'font-style':'italic','font-weight':'bold'}
};
// baseline shifts for sub and sup
@@ -383,9 +383,6 @@ exports.BR_TAG_ALL = /
/gi;
* convention and will not make a popup if this string is empty.
* per the spec, cannot contain whitespace.
*
- * Because we hack in other attributes with style (sub & sup), drop any trailing
- * semicolon in user-supplied styles so we can consistently append the tag-dependent style
- *
* These are for tag attributes; Chrome anyway will convert entities in
* attribute values, but not in attribute names
* you can test this by for example:
@@ -394,7 +391,7 @@ exports.BR_TAG_ALL = /
/gi;
* > p.innerHTML
* <- 'Hi'
*/
-var STYLEMATCH = /(^|[\s"'])style\s*=\s*("([^"]*);?"|'([^']*);?')/i;
+var STYLEMATCH = /(^|[\s"'])style\s*=\s*("([^"]*)"|'([^']*)')/i;
var HREFMATCH = /(^|[\s"'])href\s*=\s*("([^"]*)"|'([^']*)')/i;
var TARGETMATCH = /(^|[\s"'])target\s*=\s*("([^"\s]*)"|'([^'\s]*)')/i;
var POPUPMATCH = /(^|[\s"'])popup\s*=\s*("([\w=,]*)"|'([\w=,]*)')/i;
@@ -495,7 +492,8 @@ var entityToUnicode = {
nbsp: ' ',
times: '×',
plusmn: '±',
- deg: '°'
+ deg: '°',
+ quot: "'",
};
// NOTE: in general entities can contain uppercase too (so [a-zA-Z]) but all the
@@ -537,6 +535,50 @@ function fromCodePoint(code) {
);
}
+var SPLIT_STYLES = /([^;]+;|$)|&(#\d+|#x[\da-fA-F]+|[a-z]+);/;
+
+var ONE_STYLE = /^\s*([^:]+)\s*:\s*(.+?)\s*;?$/i;
+
+function applyStyles(node, styles) {
+ var parts = styles.split(SPLIT_STYLES);
+ var filteredParts = [];
+ for(var i = 0; i < parts.length; i++) {
+ if(parts[i] && typeof parts[i] === "string" && parts[i].length > 0) {
+ filteredParts.push(parts[i]);
+ }
+ }
+ parts = filteredParts;
+
+ for(var i = 0; i < parts.length; i++) {
+ var parti = parts[i];
+
+ // Recombine parts that was split due to HTML entity's semicolon
+ var partToSearch = parti;
+ do {
+ var matchEntity = partToSearch.match(ENTITY_MATCH);
+ if(matchEntity) {
+ var entity = matchEntity[0];
+ partToSearch = parts[i+1];
+ if(parti.endsWith(entity) && partToSearch) {
+ // Matched HTML entity is at the end, and thus, need to
+ // combine with next part to complete the style (when it ends
+ // with a semicolon that is not part of a HTML entity)
+ parti += partToSearch;
+ i++;
+ }
+ } else {
+ partToSearch = undefined;
+ }
+ } while (partToSearch);
+
+ var match = parti.match(ONE_STYLE);
+ if(match) {
+ var decodedStyle = convertEntities(match[2]);
+ d3.select(node).style(match[1], decodedStyle);
+ }
+ }
+}
+
/*
* buildSVGText: convert our pseudo-html into SVG tspan elements, and attach these
* to containerNode
@@ -613,8 +655,6 @@ function buildSVGText(containerNode, str) {
}
} else nodeType = 'tspan';
- if(nodeSpec.style) nodeAttrs.style = nodeSpec.style;
-
var newNode = document.createElementNS(xmlnsNamespaces.svg, nodeType);
if(type === 'sup' || type === 'sub') {
@@ -633,6 +673,10 @@ function buildSVGText(containerNode, str) {
}
d3.select(newNode).attr(nodeAttrs);
+ if(nodeSpec.style) applyStyles(newNode, nodeSpec.style)
+ if(nodeSpec.tagStyle) {
+ d3.select(newNode).style(nodeSpec.tagStyle);
+ }
currentNode = nodeSpec.node = newNode;
nodeStack.push(nodeSpec);
@@ -693,10 +737,10 @@ function buildSVGText(containerNode, str) {
var css = getQuotedMatch(extra, STYLEMATCH);
if(css) {
css = css.replace(COLORMATCH, '$1 fill:');
- if(tagStyle) css += ';' + tagStyle;
- } else if(tagStyle) css = tagStyle;
+ }
if(css) nodeSpec.style = css;
+ if(tagStyle) nodeSpec.tagStyle = tagStyle;
if(tagType === 'a') {
hasLink = true;
@@ -770,7 +814,7 @@ exports.sanitizeHTML = function sanitizeHTML(str) {
var extra = match[4];
var css = getQuotedMatch(extra, STYLEMATCH);
- var nodeAttrs = css ? {style: css} : {};
+ var nodeAttrs = {};
if(tagType === 'a') {
var href = getQuotedMatch(extra, HREFMATCH);
@@ -790,6 +834,7 @@ exports.sanitizeHTML = function sanitizeHTML(str) {
var newNode = document.createElement(tagType);
currentNode.appendChild(newNode);
d3.select(newNode).attr(nodeAttrs);
+ if(css) applyStyles(newNode, css);
currentNode = newNode;
nodeStack.push(newNode);
diff --git a/test/image/mocks/pseudo_html.json b/test/image/mocks/pseudo_html.json
index b2d9deb8d11..1762be0c712 100644
--- a/test/image/mocks/pseudo_html.json
+++ b/test/image/mocks/pseudo_html.json
@@ -3,7 +3,7 @@
{
"x": ["b i", "line 1
line 2"],
"y": ["sub1", "sup2"],
- "name": "test pseudoHTML
3H2O is heavy!
and Fonts,
oh my?"
+ "name": "test pseudoHTML
3H2O is heavy!
and Fonts,
oh my?"
}
],
"layout": {
diff --git a/test/jasmine/tests/hover_label_test.js b/test/jasmine/tests/hover_label_test.js
index a9af59100c0..9f77b97086f 100644
--- a/test/jasmine/tests/hover_label_test.js
+++ b/test/jasmine/tests/hover_label_test.js
@@ -748,7 +748,7 @@ describe('hover info', function() {
it('provides exponents correctly for z data', function(done) {
function expFmt(val, exp) {
- return val + '×10\u200b' +
+ return val + '×10\u200b' +
(exp < 0 ? MINUS_SIGN + -exp : exp) +
'\u200b';
}
@@ -2071,7 +2071,7 @@ describe('hover info', function() {
expect(hoverTrace.y).toEqual(1);
assertHoverLabelContent({
- nums: '$1.00\nPV learning curve.txt',
+ nums: '$1.00\nPV learning curve.txt',
name: '',
axis: '0.388'
});
@@ -2120,7 +2120,7 @@ describe('hover info', function() {
expect(hoverTrace.y).toEqual(1);
assertHoverLabelContent({
- nums: 'Cost ($/WP):$1.00\nCumulative Production (GW):0.3880',
+ nums: 'Cost ($/WP):$1.00\nCumulative Production (GW):0.3880',
name: '',
axis: '0.388'
});
diff --git a/test/jasmine/tests/icicle_test.js b/test/jasmine/tests/icicle_test.js
index 7032478731c..b7efca690f5 100644
--- a/test/jasmine/tests/icicle_test.js
+++ b/test/jasmine/tests/icicle_test.js
@@ -635,7 +635,7 @@ describe('Test icicle hover:', function() {
exp: {
label: {
nums: 'Abel :: 6.00',
- name: 'N.B.'
+ name: 'N.B.'
},
ptData: {
curveNumber: 0,
diff --git a/test/jasmine/tests/sunburst_test.js b/test/jasmine/tests/sunburst_test.js
index 719d9f61bae..d5bb80cb175 100644
--- a/test/jasmine/tests/sunburst_test.js
+++ b/test/jasmine/tests/sunburst_test.js
@@ -648,7 +648,7 @@ describe('Test sunburst hover:', function() {
exp: {
label: {
nums: 'Abel :: 6.00',
- name: 'N.B.'
+ name: 'N.B.'
},
ptData: {
curveNumber: 0,
diff --git a/test/jasmine/tests/svg_text_utils_test.js b/test/jasmine/tests/svg_text_utils_test.js
index 307f8d8c45c..ad88eedf3a2 100644
--- a/test/jasmine/tests/svg_text_utils_test.js
+++ b/test/jasmine/tests/svg_text_utils_test.js
@@ -64,8 +64,8 @@ describe('svg+text utils', function() {
var style = expectedAttrs.style || '';
var fullStyle = style || '';
- if(style) fullStyle += ';';
- fullStyle += 'cursor:pointer';
+ if(style) fullStyle += '; ';
+ fullStyle += 'cursor: pointer;';
expect(a.attr('style')).toBe(fullStyle, msg);
@@ -167,27 +167,27 @@ describe('svg+text utils', function() {
var node = mockTextSVGElement(textCase);
expect(node.text()).toEqual('Subtitle');
- assertAnchorAttrs(node, {style: 'font-size:300px'});
+ assertAnchorAttrs(node, {style: 'font-size: 300px'});
assertAnchorLink(node, 'XSS');
});
});
it('accepts href and style in in any order and tosses other stuff', function() {
var textCases = [
- 'z',
- 'z',
- 'z',
- 'z',
- 'z',
- 'z',
- 'z',
+ 'z',
+ 'z',
+ 'z',
+ 'z',
+ 'z',
+ 'z',
+ 'z',
];
textCases.forEach(function(textCase) {
var node = mockTextSVGElement(textCase);
expect(node.text()).toEqual('z');
- assertAnchorAttrs(node, {style: 'y'});
+ assertAnchorAttrs(node, {style: 'font-variant: small-caps'});
assertAnchorLink(node, 'x');
});
});
@@ -288,29 +288,47 @@ describe('svg+text utils', function() {
it('allows quoted styles in spans', function() {
var node = mockTextSVGElement(
- 'text'
+ 'text'
);
expect(node.text()).toEqual('text');
- assertTspanStyle(node, 'quoted: yeah;');
+ assertTspanStyle(node, 'fill: green;');
+ });
+
+ it('adjusts quoted styles in spans', function() {
+ var node = mockTextSVGElement(
+ 'text'
+ );
+
+ expect(node.text()).toEqual('text');
+ assertTspanStyle(node, 'fill: green;');
});
it('ignores extra stuff after span styles', function() {
var node = mockTextSVGElement(
- 'text'
+ 'text'
);
expect(node.text()).toEqual('text');
- assertTspanStyle(node, 'quoted: yeah;');
+ assertTspanStyle(node, 'fill: green;');
});
- it('escapes HTML entities in span styles', function() {
+ it('decodes some HTML entities in span styles', function() {
var node = mockTextSVGElement(
- 'text'
+ 'text'
);
expect(node.text()).toEqual('text');
- assertTspanStyle(node, 'quoted: yeah&\';;');
+ assertTspanStyle(node, "font-family: \"Times New Roman\";");
+ });
+
+ it('ignores invalid HTML entities in span styles', function() {
+ var node = mockTextSVGElement(
+ 'text'
+ );
+
+ expect(node.text()).toEqual('text');
+ assertTspanStyle(node, null);
});
it('decodes some HTML entities in text', function() {
@@ -367,30 +385,30 @@ describe('svg+text utils', function() {
var controlNode = mockTextSVGElement('bold');
expect(controlNode.html()).toBe(
- 'bold'
+ 'bold'
);
});
it('supports superscript by itself', function() {
var node = mockTextSVGElement('123');
expect(node.html()).toBe(
- '\u200b123' +
+ '\u200b123' +
'\u200b');
});
it('supports subscript by itself', function() {
var node = mockTextSVGElement('123');
expect(node.html()).toBe(
- '\u200b123' +
+ '\u200b123' +
'\u200b');
});
it('supports superscript and subscript together with normal text', function() {
var node = mockTextSVGElement('SO42-');
expect(node.html()).toBe(
- 'SO\u200b4' +
+ 'SO\u200b4' +
'\u200b\u200b' +
- '2-' +
+ '2-' +
'\u200b');
});
@@ -398,22 +416,22 @@ describe('svg+text utils', function() {
var node = mockTextSVGElement('be Bold
and
Strong');
expect(node.html()).toBe(
'be ' +
- 'Bold' +
+ 'Bold' +
'' +
- 'and' +
+ 'and' +
'' +
- '' +
- 'Strong');
+ '' +
+ 'Strong');
});
it('allows one to span
s', function() {
var node = mockTextSVGElement('SO4
44');
expect(node.html()).toBe(
'SO\u200b' +
- '4' +
+ '4' +
'\u200b' +
'\u200b' +
- '44' +
+ '44' +
'\u200b');
});
@@ -428,9 +446,9 @@ describe('svg+text utils', function() {
var node = mockTextSVGElement(textCase);
function opener(dy) {
return '' +
- '' +
- '' +
- '\u200b';
+ '' +
+ '' +
+ '\u200b';
}
var closer = '\u200b' +
'';
@@ -589,25 +607,25 @@ describe('sanitizeHTML', function() {
textCases.forEach(function(textCase) {
var innerHTML = mockHTML(textCase);
- expect(innerHTML).toEqual('Subtitle');
+ 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',
+ 'z',
+ 'z',
+ 'z',
+ 'z',
+ 'z',
+ 'z',
+ 'z',
];
textCases.forEach(function(textCase) {
var innerHTML = mockHTML(textCase);
- expect(innerHTML).toEqual('z');
+ expect(innerHTML).toEqual('z');
});
});
diff --git a/test/jasmine/tests/treemap_test.js b/test/jasmine/tests/treemap_test.js
index d70ad287b31..b2cdafe2f33 100644
--- a/test/jasmine/tests/treemap_test.js
+++ b/test/jasmine/tests/treemap_test.js
@@ -737,7 +737,7 @@ describe('Test treemap hover:', function() {
exp: {
label: {
nums: 'Abel :: 6.00',
- name: 'N.B.'
+ name: 'N.B.'
},
ptData: {
curveNumber: 0,