CVE-2024-23841: Improper Neutralization of Script-Related HTML Tags in a Web Page (Basic XSS) (CWE-80)
value: apollo-client-nextjs is the Apollo Client support for the Next.js App Router. The @apollo/experimental-apollo-client-nextjs NPM package is vulnerable to a cross-site scripting vulnerability. To exploit this vulnerability, an attacker would need to either inject malicious input (e.g. by redirecting a user to a specifically-crafted link) or arrange to have malicious input be returned by a GraphQL server (e.g. by persisting it in a database). To fix this issue, please update to version 0.7.0 or later.
CWE description: The product receives input from an upstream component, but it does not neutralize or incorrectly neutralizes special characters such as "<", ">", and "&" that could be interpreted as web-scripting elements when they are sent to a downstream component that processes web pages.
CVSS v3 base score: 6.1
Published: 2024-01-30T18:15Z
Repository: https://github.com/apollographql/apollo-client-nextjs
Language: TypeScript
Files changed: package/package.json, package/src/ssr/dataTransport.ts, package/src/util/htmlescape.ts
Commit date: 2024-01-30 18:00:32 +0100
Commit message: Merge pull request from GHSA-rv8p-rr2h-fgpg

* escape html characters in JSON string before injecting it into stream

* increment package version
Diff: diff --git a/package/package.json b/package/package.json
index 720de42..9e94273 100644
--- a/package/package.json
+++ b/package/package.json
@@ -1,8 +1,8 @@
 {
   "name": "@apollo/experimental-nextjs-app-support",
-  "version": "0.6.0",
+  "version": "0.7.0",
   "repository": {
     "url": "git+https://github.com/apollographql/apollo-client-nextjs"
   },
   "keywords": [
     "apollo",
diff --git a/package/src/ssr/dataTransport.ts b/package/src/ssr/dataTransport.ts
index 12525b0..ba7c704 100644
--- a/package/src/ssr/dataTransport.ts
+++ b/package/src/ssr/dataTransport.ts
@@ -7,10 +7,11 @@ import {
 } from "./ApolloRehydrateSymbols";
 import type { RehydrationCache } from "./types";
 import { registerLateInitializingQueue } from "./lateInitializingQueue";
 import type { Cache, WatchQueryOptions } from "@apollo/client";
 import invariant from "ts-invariant";
+import { htmlEscapeJsonString } from "../util/htmlescape";
 
 export type DataTransport<T> = Array<T> | { push(...args: T[]): void };
 
 type DataToTransport = {
   rehydrate: RehydrationCache;
@@ -21,12 +22,12 @@ type DataToTransport = {
 /**
  * Returns a string of JavaScript that can be used to transport data to the client.
  */
 export function transportDataToJS(data: DataToTransport) {
   const key = Symbol.keyFor(ApolloSSRDataTransport);
-  return `(window[Symbol.for("${key}")] ??= []).push(${SuperJSON.stringify(
-    data
+  return `(window[Symbol.for("${key}")] ??= []).push(${htmlEscapeJsonString(
+    SuperJSON.stringify(data)
   )})`;
 }
 
 /**
  * Registers a lazy queue that will be filled with data by `transportDataToJS`.
diff --git a/package/src/util/htmlescape.ts b/package/src/util/htmlescape.ts
new file mode 100644
index 0000000..be7acf7
--- /dev/null
+++ b/package/src/util/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:   "version": "0.6.0",
  return `(window[Symbol.for("${key}")] ??= []).push(${SuperJSON.stringify(
    data
Fixed code:   "version": "0.7.0",
import { htmlEscapeJsonString } from "../util/htmlescape";
  return `(window[Symbol.for("${key}")] ??= []).push(${htmlEscapeJsonString(
    SuperJSON.stringify(data)
// --------------------------------------------------------------------------------
//
// 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]);
}