Skip to content

Commit

Permalink
support spaces in style data-hrefs attribute by escaping them
Browse files Browse the repository at this point in the history
  • Loading branch information
gnoff committed Mar 3, 2023
1 parent 0008afb commit 7ea792d
Show file tree
Hide file tree
Showing 3 changed files with 144 additions and 10 deletions.
46 changes: 40 additions & 6 deletions packages/react-dom-bindings/src/client/ReactDOMFloatClient.js
Original file line number Diff line number Diff line change
Expand Up @@ -661,21 +661,34 @@ function styleTagPropsFromRawProps(
}

function getStyleKey(href: string) {
const limitedEscapedHref =
escapeSelectorAttributeValueInsideDoubleQuotes(href);
return `href~="${limitedEscapedHref}"`;
return href;
}

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

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

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

function stylesheetPropsFromRawProps(
Expand Down Expand Up @@ -1132,3 +1145,24 @@ function escapeSelectorAttributeValueInsideDoubleQuotes(value: string): string {
ch => '\\' + ch.charCodeAt(0).toString(16),
);
}

// For <style> we use a space separated match against href and thus need to ensure that our href
// value does not contain any spaces in addition to the security based escaping for serialized text
// in attribute position. Note the space in the Regex matcher.
const escapeStyleHrefAttributeValueInsideDoubleQuotesRegex = /[ \n\"\\]/g;
function escapeStyleHrefAttributeValueInsideDoubleQuotes(
value: string,
): string {
return value.replace(
escapeStyleHrefAttributeValueInsideDoubleQuotesRegex,
ch => {
// For style href attributes specifically we use the attribute whitespace separated list selector/
// If our href has a space in it unecoded it will never match using this selector type so we encode
// spaces.
if (ch === ' ') {
return '%20';
}
return '\\' + ch.charCodeAt(0).toString(16);
},
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -3296,6 +3296,33 @@ function escapeJSObjectForInstructionScripts(input: Object): string {
});
}

const regexForStyleTagHrefStringInHTMLAttribute = /[ "&'<>]/g;
function escapeStyleTagHrefInHTMLAttribute(input: string): string {
return input.replace(regexForStyleTagHrefStringInHTMLAttribute, match => {
switch (match) {
// santizing breaking out of strings and script tags
case ' ':
return '%20';
case '"':
return '&quot;';
case '&':
return '&amp;';
case "'":
return '&#x27;'; // modified from escape-html; used to be '&#39'
case '<':
return '&lt;';
case '>':
return '&gt;';
default: {
// eslint-disable-next-line react-internal/prod-error-codes
throw new Error(
'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',
);
}
}
});
}

const lateStyleTagResourceOpen1 = stringToPrecomputedChunk(
'<style media="not all" data-precedence="',
);
Expand Down Expand Up @@ -3332,10 +3359,16 @@ function flushStyleTagsLateForBoundary(
if (hrefs.length) {
writeChunk(this, lateStyleTagResourceOpen2);
for (; i < hrefs.length - 1; i++) {
writeChunk(this, stringToChunk(escapeTextForBrowser(hrefs[i])));
writeChunk(
this,
stringToChunk(escapeStyleTagHrefInHTMLAttribute(hrefs[i])),
);
writeChunk(this, spaceSeparator);
}
writeChunk(this, stringToChunk(escapeTextForBrowser(hrefs[i])));
writeChunk(
this,
stringToChunk(escapeStyleTagHrefInHTMLAttribute(hrefs[i])),
);
}
writeChunk(this, lateStyleTagResourceOpen3);
for (i = 0; i < chunks.length; i++) {
Expand Down Expand Up @@ -3464,10 +3497,16 @@ function flushAllStylesInPreamble(
if (hrefs.length) {
writeChunk(this, styleTagResourceOpen2);
for (; i < hrefs.length - 1; i++) {
writeChunk(this, stringToChunk(escapeTextForBrowser(hrefs[i])));
writeChunk(
this,
stringToChunk(escapeStyleTagHrefInHTMLAttribute(hrefs[i])),
);
writeChunk(this, spaceSeparator);
}
writeChunk(this, stringToChunk(escapeTextForBrowser(hrefs[i])));
writeChunk(
this,
stringToChunk(escapeStyleTagHrefInHTMLAttribute(hrefs[i])),
);
}
writeChunk(this, styleTagResourceOpen3);
for (i = 0; i < chunks.length; i++) {
Expand Down
61 changes: 61 additions & 0 deletions packages/react-dom/src/__tests__/ReactDOMFloat-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4424,6 +4424,67 @@ background-color: green;
</html>,
);
});

it('can handle hrefs with a special characters in them', async () => {
function App() {
return (
<html>
<body>
<style href="before" precedence="default">
before
</style>
<style href={'" \' & \n < > \\ '} precedence="default">
everything
</style>
<style href={' '} precedence="default">
space
</style>
<style href={'foo bar\\'} precedence="default">
foo bar\
</style>
<style href="after" precedence="default">
after
</style>
</body>
</html>
);
}
await actIntoEmptyDocument(() => {
renderToPipeableStream(<App />).pipe(writable);
});
expect(getMeaningfulChildren(document)).toEqual(
<html>
<head>
<style
data-href={
'before "%20\'%20&%20\n%20<%20>%20\\%20%20%20 %20 foo%20bar\\ after'
}
data-precedence="default">
beforeeverythingspacefoo bar\after
</style>
</head>
<body />
</html>,
);

// If the hydration selector failed to match on quote
ReactDOMClient.hydrateRoot(document, <App />);
expect(Scheduler).toFlushWithoutYielding();
expect(getMeaningfulChildren(document)).toEqual(
<html>
<head>
<style
data-href={
'before "%20\'%20&%20\n%20<%20>%20\\%20%20%20 %20 foo%20bar\\ after'
}
data-precedence="default">
beforeeverythingspacefoo bar\after
</style>
</head>
<body />
</html>,
);
});
});

describe('Script Resources', () => {
Expand Down

0 comments on commit 7ea792d

Please sign in to comment.