CVE-2024-24558: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting') (CWE-79)
value: TanStack Query supplies asynchronous state management, server-state utilities and data fetching for the web.  The `@tanstack/react-query-next-experimental` NPM package is vulnerable to a cross-site scripting vulnerability. To exploit this, an attacker would need to either inject malicious input or arrange to have malicious input be returned from an endpoint. To fix this issue, please update to version 5.18.0 or later.

CWE description: The product does not neutralize or incorrectly neutralizes user-controllable input before it is placed in output that is used as a web page that is served to other users.
CVSS v3 base score: 6.1
Published: 2024-01-30T20:15Z
Repository: https://github.com/TanStack/query
Language: Other
Files changed: packages/react-query-next-experimental/src/HydrationStreamProvider.tsx, packages/react-query-next-experimental/src/htmlescape.ts
Commit date: 2024-01-30 18:21:09 +0100
Commit message: Merge pull request from GHSA-997g-27x8-43rf

Co-authored-by: Lenz Weber-Tronic <lorenz.weber-tronic@apollographql.com>
Diff: diff --git a/packages/react-query-next-experimental/src/HydrationStreamProvider.tsx b/packages/react-query-next-experimental/src/HydrationStreamProvider.tsx
index 05da90dd..936056a3 100644
--- a/packages/react-query-next-experimental/src/HydrationStreamProvider.tsx
+++ b/packages/react-query-next-experimental/src/HydrationStreamProvider.tsx
@@ -1,9 +1,10 @@
 'use client'
 
 import { useServerInsertedHTML } from 'next/navigation'
 import * as React from 'react'
+import { htmlEscapeJsonString } from './htmlescape'
 
 const serializedSymbol = Symbol('serialized')
 
 interface DataTransformer {
   serialize(object: any): any
@@ -81,11 +82,11 @@ export function createHydrationStreamProvider<TShape>() {
      */
     onFlush?: () => Array<TShape>
   }) {
     // unique id for the cache provider
     const id = `__RQ${React.useId()}`
-    const idJSON = JSON.stringify(id)
+    const idJSON = htmlEscapeJsonString(JSON.stringify(id))
 
     const [transformer] = React.useState(
       () =>
         (props.transformer ?? {
           // noop
@@ -122,11 +123,11 @@ export function createHydrationStreamProvider<TShape>() {
       // Flush stream
       stream.length = 0
 
       const html: Array<string> = [
         `window[${idJSON}] = window[${idJSON}] || [];`,
-        `window[${idJSON}].push(${serializedCacheArgs});`,
+        `window[${idJSON}].push(${htmlEscapeJsonString(serializedCacheArgs)});`,
       ]
       return (
         <script
           key={count.current++}
           dangerouslySetInnerHTML={{
diff --git a/packages/react-query-next-experimental/src/htmlescape.ts b/packages/react-query-next-experimental/src/htmlescape.ts
new file mode 100644
index 00000000..be7acf73
--- /dev/null
+++ b/packages/react-query-next-experimental/src/htmlescape.ts
@@ -0,0 +1,24 @@
+// --------------------------------------------------------------------------------
+//
+// copied from
+// https://github.com/vercel/next.js/blob/6bc07792a4462a4bf921a72ab30dc4ab2c4e1bda/packages/next/src/server/htmlescape.ts
+// License: https://github.com/vercel/next.js/blob/6bc07792a4462a4bf921a72ab30dc4ab2c4e1bda/packages/next/license.md
+//
+// --------------------------------------------------------------------------------
+
+// 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",
+};
+
+export const ESCAPE_REGEX = /[&><\u2028\u2029]/g;
+
+export function htmlEscapeJsonString(str: string): string {
+  return str.replace(ESCAPE_REGEX, (match) => ESCAPE_LOOKUP[match]);
+}

Vulnerable code:     const idJSON = JSON.stringify(id)
        `window[${idJSON}].push(${serializedCacheArgs});`,
Fixed code: import { htmlEscapeJsonString } from './htmlescape'
    const idJSON = htmlEscapeJsonString(JSON.stringify(id))
        `window[${idJSON}].push(${htmlEscapeJsonString(serializedCacheArgs)});`,
// --------------------------------------------------------------------------------
//
// copied from
// https://github.com/vercel/next.js/blob/6bc07792a4462a4bf921a72ab30dc4ab2c4e1bda/packages/next/src/server/htmlescape.ts
// License: https://github.com/vercel/next.js/blob/6bc07792a4462a4bf921a72ab30dc4ab2c4e1bda/packages/next/license.md
//
// --------------------------------------------------------------------------------

// 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",
};

export const ESCAPE_REGEX = /[&><\u2028\u2029]/g;

export function htmlEscapeJsonString(str: string): string {
  return str.replace(ESCAPE_REGEX, (match) => ESCAPE_LOOKUP[match]);
}