diff --git a/.changeset/witty-melons-sip.md b/.changeset/witty-melons-sip.md new file mode 100644 index 0000000000..6cd18b1fc7 --- /dev/null +++ b/.changeset/witty-melons-sip.md @@ -0,0 +1,5 @@ +--- +"react-router-dom": patch +--- + +Properly escape HTML characters in StaticRouterProvider serialized hydration data diff --git a/contributors.yml b/contributors.yml index adb8a0746c..8340c2f402 100644 --- a/contributors.yml +++ b/contributors.yml @@ -178,3 +178,4 @@ - xavier-lc - xcsnowcity - yuleicul +- zheng-chuang diff --git a/packages/react-router-dom/__tests__/data-static-router-test.tsx b/packages/react-router-dom/__tests__/data-static-router-test.tsx index 3f280d287a..839d5d24c0 100644 --- a/packages/react-router-dom/__tests__/data-static-router-test.tsx +++ b/packages/react-router-dom/__tests__/data-static-router-test.tsx @@ -269,6 +269,37 @@ describe("A ", () => { ); }); + it("escapes HTML tags in serialized hydration data", async () => { + let routes = [ + { + path: "/", + loader: () => ({ + key: "uh oh", + }), + element:

👋

, + }, + ]; + let { query } = createStaticHandler(routes); + + let context = (await query( + new Request("http://localhost/", { + signal: new AbortController().signal, + }) + )) as StaticHandlerContext; + + let html = ReactDOMServer.renderToStaticMarkup( + + + + ); + expect(html).toMatchInlineSnapshot( + `"

👋

"` + ); + }); + it("serializes ErrorResponse instances", async () => { let routes = [ { diff --git a/packages/react-router-dom/server.tsx b/packages/react-router-dom/server.tsx index 6e9c6cd365..47c18dd700 100644 --- a/packages/react-router-dom/server.tsx +++ b/packages/react-router-dom/server.tsx @@ -114,7 +114,7 @@ export function StaticRouterProvider({ // up parsing on the client. Dual-stringify is needed to ensure all quotes // are properly escaped in the resulting string. See: // https://v8.dev/blog/cost-of-javascript-2019#json - let json = JSON.stringify(JSON.stringify(data)); + let json = htmlEscape(JSON.stringify(JSON.stringify(data))); hydrateScript = `window.__staticRouterHydrationData = JSON.parse(${json});`; } @@ -323,3 +323,19 @@ function encodeLocation(to: To): Path { hash: path.hash || "", }; } + +// This utility is based on https://github.com/zertosh/htmlescape +// License: https://github.com/zertosh/htmlescape/blob/0527ca7156a524d256101bb310a9f970f63078ad/LICENSE +const ESCAPE_LOOKUP: { [match: string]: string } = { + "&": "\\u0026", + ">": "\\u003e", + "<": "\\u003c", + "\u2028": "\\u2028", + "\u2029": "\\u2029", +}; + +const ESCAPE_REGEX = /[&><\u2028\u2029]/g; + +function htmlEscape(str: string): string { + return str.replace(ESCAPE_REGEX, (match) => ESCAPE_LOOKUP[match]); +}