Skip to content

fix(document): HTML-escape NextScript.getInlineScriptSource output - #2727

Merged
james-elicx merged 3 commits into
cloudflare:mainfrom
NathanDrake2406:nathan/document-inline-script-escaping
Jul 29, 2026
Merged

fix(document): HTML-escape NextScript.getInlineScriptSource output#2727
james-elicx merged 3 commits into
cloudflare:mainfrom
NathanDrake2406:nathan/document-inline-script-escaping

Conversation

@NathanDrake2406

Copy link
Copy Markdown
Contributor

Problem

NextScript.getInlineScriptSource() in the next/document shim returned raw JSON.stringify(context.__NEXT_DATA__):

static getInlineScriptSource(context: Readonly<HtmlProps>): string {
  return JSON.stringify(context.__NEXT_DATA__);
}

JSON.stringify does not escape characters the HTML parser treats as significant. A string value containing </script> terminates the inline script the result is embedded in, and everything after it is parsed as HTML rather than as script content. The same applies to <, >, &, U+2028 and U+2029.

Why it matters

This method exists so a custom _document can place __NEXT_DATA__ into its own inline <script>. Next.js returns htmlEscapeJsonString(data) from every return path of the equivalent method (packages/next/src/pages/_document.tsx), so an app that relied on that escaping silently lost it here — page props or query-derived values become script injection on the app's origin.

The default renderer was never affected: pages-page-response.ts already builds its __NEXT_DATA__ tag with safeJsonStringify. Only the custom-_document path diverged.

Fix

Call safeJsonStringify — the escaper already used for the framework-generated tag — so both paths emit identical output. server/html.ts has no imports, so pulling it into the shim adds no dependency weight.

Testing

  • New test in tests/document.test.ts asserts the output contains no literal </script>, contains the escaped form, and still round-trips through JSON.parse to the original value.
  • Verified the test fails against the previous implementation and passes with the fix.
  • tests/document.test.ts + tests/safe-json.test.ts: 45 passing. Repo pre-commit full check, unit/integration suites and knip all passed.

No behavior change for apps using the default document.

The shim returned raw JSON.stringify(context.__NEXT_DATA__). JSON.stringify
does not escape characters the HTML parser treats as significant, so a string
value containing "</script>" terminates the inline script it is embedded in
and the remainder is parsed as HTML.

Next.js returns htmlEscapeJsonString(data) from every path of this method
(pages/_document.tsx). A custom _document that calls the helper and inlines
the result — the pattern the method exists for — therefore loses escaping it
would keep on Next.js, turning page props or query-derived data into script
injection.

Use safeJsonStringify, the escaper this repo already applies to the
framework-generated __NEXT_DATA__ tag in pages-page-response.ts, so both
paths produce the same output. The default renderer was unaffected.
@pkg-pr-new

pkg-pr-new Bot commented Jul 27, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@vinext/cloudflare@2727
npm i https://pkg.pr.new/create-vinext-app@2727
npm i https://pkg.pr.new/@vinext/types@2727
npm i https://pkg.pr.new/vinext@2727

commit: 349f8cd

@github-actions

github-actions Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared 349f8cd against base 25dc2f3 using alternating same-runner rounds. Next.js was unchanged and skipped.

1 improved · 0 regressed · 5 within ±1.5%

Scenario Framework Baseline Current Change
Client bundle size (gzip) vinext 134.3 KB 134.3 KB ⚫ +0.0%
Client entry size (gzip) vinext 121.9 KB 121.9 KB ⚫ +0.0%
Dev server cold start vinext 2.95 s 2.87 s 🟢 -2.7%
Production build time vinext 3.20 s 3.18 s ⚫ -0.8%
RSC entry closure size (gzip) vinext 111.3 KB 111.3 KB ⚫ -0.0%
Server bundle size (gzip) vinext 189.0 KB 189.0 KB ⚫ -0.0%

View detailed results and traces

🟢 improvement · 🔴 regression · ⚫ change below 1.5% · paired base/head

@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

@ask-bonk

ask-bonk Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

UnknownError: ProviderInitError

github run

@ask-bonk

ask-bonk Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

@james-elicx Bonk workflow failed. Check the logs for details.

View workflow run · To retry, trigger Bonk again.

@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: correct, well-scoped security fix

Verified the fix against the authoritative Next.js source and ran the local checks.

Vulnerability is real. JSON.stringify doesn't escape <, >, &, U+2028, U+2029. A page-data string value containing </script> terminates the inline <script> in a custom _document, and the remainder is parsed as HTML — a stored XSS vector on the app's origin.

Fix matches Next.js exactly. I confirmed against vercel/next.js canary packages/next/src/pages/_document.tsx:858 that getInlineScriptSource returns htmlEscapeJsonString(data) on every return path. vinext's safeJsonStringify (packages/vinext/src/server/html.ts:21) escapes the same character set (<\u003c, >\u003e, &\u0026, \u2028, \u2029).

No double-escaping risk. getInlineScriptSource has no internal callers — it is only invoked by user custom _document code (as in Next.js). The default renderer builds its own __NEXT_DATA__ tag separately via safeJsonStringify (pages-page-response.ts:290), so both paths now emit identical output.

Import is fine. next/document is a server-only shim, so pulling in server/html.js adds no client-bundle weight, and server/html.ts has no imports of its own. 24 existing shims already import from ../server/, so the pattern is established.

Local verification.

  • vp test run tests/document.test.ts → 15 passed
  • vp check on both changed files → format, lint, and type checks clean

The new test correctly asserts no literal </script>, no raw <>&/line separators, the presence of the escaped forms, and a JSON.parse round-trip back to the original value.

LGTM.

@ask-bonk

ask-bonk Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Review posted successfully as an approval on PR #2727.

Summary

I reviewed PR #2727 (fix(document): HTML-escape NextScript.getInlineScriptSource output) and approved it. My findings:

The fix is correct and well-scoped:

  • Real vulnerability: JSON.stringify doesn't escape <, >, &, U+2028, U+2029. A custom _document embedding this output in an inline <script> was vulnerable to script breakout / stored XSS via page data containing </script>.
  • Matches Next.js exactly: I confirmed against vercel/next.js canary _document.tsx:858 that getInlineScriptSource returns htmlEscapeJsonString(data) on every path. vinext's safeJsonStringify escapes the identical character set.
  • No double-escaping risk: getInlineScriptSource has no internal callers (only user custom _document); the default renderer builds its tag separately via the same safeJsonStringify, so both paths now produce identical output.
  • Import is clean: next/document is server-only, server/html.ts has no imports, and 24 existing shims already import from ../server/.

Verification I ran locally:

  • vp test run tests/document.test.ts → 15 passed
  • vp check on both changed files → format, lint, and type checks all clean

No issues found. The change is minimal, correct, matches Next.js behavior, and is properly tested.

github run

@james-elicx
james-elicx merged commit 67e8b2b into cloudflare:main Jul 29, 2026
100 of 102 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants