Skip to content

Commit 7ea792d

Browse files
committed
support spaces in style data-hrefs attribute by escaping them
1 parent 0008afb commit 7ea792d

File tree

3 files changed

+144
-10
lines changed

3 files changed

+144
-10
lines changed

packages/react-dom-bindings/src/client/ReactDOMFloatClient.js

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -661,21 +661,34 @@ function styleTagPropsFromRawProps(
661661
}
662662

663663
function getStyleKey(href: string) {
664-
const limitedEscapedHref =
665-
escapeSelectorAttributeValueInsideDoubleQuotes(href);
666-
return `href~="${limitedEscapedHref}"`;
664+
return href;
667665
}
668666

669667
function getStyleTagSelectorFromKey(key: string) {
670-
return `style[data-${key}]`;
668+
// Key is actually the href
669+
// @TODO refactor. It was convient before when the key being the selector was sufficient
670+
// but with the complexities introduced with <style> support this structure makes less sense.
671+
const limitedEscapedHref =
672+
escapeStyleHrefAttributeValueInsideDoubleQuotes(key);
673+
return `style[data-href~="${limitedEscapedHref}"]`;
671674
}
672675

673676
function getStylesheetSelectorFromKey(key: string) {
674-
return `link[rel="stylesheet"][${key}]`;
677+
// Key is actually the href
678+
// @TODO refactor. It was convient before when the key being the selector was sufficient
679+
// but with the complexities introduced with <style> support this structure makes less sense.
680+
const limitedEscapedHref =
681+
escapeSelectorAttributeValueInsideDoubleQuotes(key);
682+
return `link[rel="stylesheet"][href="${limitedEscapedHref}"]`;
675683
}
676684

677685
function getPreloadStylesheetSelectorFromKey(key: string) {
678-
return `link[rel="preload"][as="style"][${key}]`;
686+
// Key is actually the href
687+
// @TODO refactor. It was convient before when the key being the selector was sufficient
688+
// but with the complexities introduced with <style> support this structure makes less sense.
689+
const limitedEscapedHref =
690+
escapeSelectorAttributeValueInsideDoubleQuotes(key);
691+
return `link[rel="preload"][as="style"][href="${limitedEscapedHref}"]`;
679692
}
680693

681694
function stylesheetPropsFromRawProps(
@@ -1132,3 +1145,24 @@ function escapeSelectorAttributeValueInsideDoubleQuotes(value: string): string {
11321145
ch => '\\' + ch.charCodeAt(0).toString(16),
11331146
);
11341147
}
1148+
1149+
// For <style> we use a space separated match against href and thus need to ensure that our href
1150+
// value does not contain any spaces in addition to the security based escaping for serialized text
1151+
// in attribute position. Note the space in the Regex matcher.
1152+
const escapeStyleHrefAttributeValueInsideDoubleQuotesRegex = /[ \n\"\\]/g;
1153+
function escapeStyleHrefAttributeValueInsideDoubleQuotes(
1154+
value: string,
1155+
): string {
1156+
return value.replace(
1157+
escapeStyleHrefAttributeValueInsideDoubleQuotesRegex,
1158+
ch => {
1159+
// For style href attributes specifically we use the attribute whitespace separated list selector/
1160+
// If our href has a space in it unecoded it will never match using this selector type so we encode
1161+
// spaces.
1162+
if (ch === ' ') {
1163+
return '%20';
1164+
}
1165+
return '\\' + ch.charCodeAt(0).toString(16);
1166+
},
1167+
);
1168+
}

packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3296,6 +3296,33 @@ function escapeJSObjectForInstructionScripts(input: Object): string {
32963296
});
32973297
}
32983298

3299+
const regexForStyleTagHrefStringInHTMLAttribute = /[ "&'<>]/g;
3300+
function escapeStyleTagHrefInHTMLAttribute(input: string): string {
3301+
return input.replace(regexForStyleTagHrefStringInHTMLAttribute, match => {
3302+
switch (match) {
3303+
// santizing breaking out of strings and script tags
3304+
case ' ':
3305+
return '%20';
3306+
case '"':
3307+
return '&quot;';
3308+
case '&':
3309+
return '&amp;';
3310+
case "'":
3311+
return '&#x27;'; // modified from escape-html; used to be '&#39'
3312+
case '<':
3313+
return '&lt;';
3314+
case '>':
3315+
return '&gt;';
3316+
default: {
3317+
// eslint-disable-next-line react-internal/prod-error-codes
3318+
throw new Error(
3319+
'escapeStyleTagHrefInHTMLAttribute encountered a match it does not know how to replace. this means the match regex and the replacement characters are no longer in sync. This is a bug in React',
3320+
);
3321+
}
3322+
}
3323+
});
3324+
}
3325+
32993326
const lateStyleTagResourceOpen1 = stringToPrecomputedChunk(
33003327
'<style media="not all" data-precedence="',
33013328
);
@@ -3332,10 +3359,16 @@ function flushStyleTagsLateForBoundary(
33323359
if (hrefs.length) {
33333360
writeChunk(this, lateStyleTagResourceOpen2);
33343361
for (; i < hrefs.length - 1; i++) {
3335-
writeChunk(this, stringToChunk(escapeTextForBrowser(hrefs[i])));
3362+
writeChunk(
3363+
this,
3364+
stringToChunk(escapeStyleTagHrefInHTMLAttribute(hrefs[i])),
3365+
);
33363366
writeChunk(this, spaceSeparator);
33373367
}
3338-
writeChunk(this, stringToChunk(escapeTextForBrowser(hrefs[i])));
3368+
writeChunk(
3369+
this,
3370+
stringToChunk(escapeStyleTagHrefInHTMLAttribute(hrefs[i])),
3371+
);
33393372
}
33403373
writeChunk(this, lateStyleTagResourceOpen3);
33413374
for (i = 0; i < chunks.length; i++) {
@@ -3464,10 +3497,16 @@ function flushAllStylesInPreamble(
34643497
if (hrefs.length) {
34653498
writeChunk(this, styleTagResourceOpen2);
34663499
for (; i < hrefs.length - 1; i++) {
3467-
writeChunk(this, stringToChunk(escapeTextForBrowser(hrefs[i])));
3500+
writeChunk(
3501+
this,
3502+
stringToChunk(escapeStyleTagHrefInHTMLAttribute(hrefs[i])),
3503+
);
34683504
writeChunk(this, spaceSeparator);
34693505
}
3470-
writeChunk(this, stringToChunk(escapeTextForBrowser(hrefs[i])));
3506+
writeChunk(
3507+
this,
3508+
stringToChunk(escapeStyleTagHrefInHTMLAttribute(hrefs[i])),
3509+
);
34713510
}
34723511
writeChunk(this, styleTagResourceOpen3);
34733512
for (i = 0; i < chunks.length; i++) {

packages/react-dom/src/__tests__/ReactDOMFloat-test.js

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4424,6 +4424,67 @@ background-color: green;
44244424
</html>,
44254425
);
44264426
});
4427+
4428+
it('can handle hrefs with a special characters in them', async () => {
4429+
function App() {
4430+
return (
4431+
<html>
4432+
<body>
4433+
<style href="before" precedence="default">
4434+
before
4435+
</style>
4436+
<style href={'" \' & \n < > \\ '} precedence="default">
4437+
everything
4438+
</style>
4439+
<style href={' '} precedence="default">
4440+
space
4441+
</style>
4442+
<style href={'foo bar\\'} precedence="default">
4443+
foo bar\
4444+
</style>
4445+
<style href="after" precedence="default">
4446+
after
4447+
</style>
4448+
</body>
4449+
</html>
4450+
);
4451+
}
4452+
await actIntoEmptyDocument(() => {
4453+
renderToPipeableStream(<App />).pipe(writable);
4454+
});
4455+
expect(getMeaningfulChildren(document)).toEqual(
4456+
<html>
4457+
<head>
4458+
<style
4459+
data-href={
4460+
'before "%20\'%20&%20\n%20<%20>%20\\%20%20%20 %20 foo%20bar\\ after'
4461+
}
4462+
data-precedence="default">
4463+
beforeeverythingspacefoo bar\after
4464+
</style>
4465+
</head>
4466+
<body />
4467+
</html>,
4468+
);
4469+
4470+
// If the hydration selector failed to match on quote
4471+
ReactDOMClient.hydrateRoot(document, <App />);
4472+
expect(Scheduler).toFlushWithoutYielding();
4473+
expect(getMeaningfulChildren(document)).toEqual(
4474+
<html>
4475+
<head>
4476+
<style
4477+
data-href={
4478+
'before "%20\'%20&%20\n%20<%20>%20\\%20%20%20 %20 foo%20bar\\ after'
4479+
}
4480+
data-precedence="default">
4481+
beforeeverythingspacefoo bar\after
4482+
</style>
4483+
</head>
4484+
<body />
4485+
</html>,
4486+
);
4487+
});
44274488
});
44284489

44294490
describe('Script Resources', () => {

0 commit comments

Comments
 (0)