Skip to content

fix: further escape JS expressions - #227

Merged
brillout merged 10 commits into
mainfrom
claude/vike-issue-3463-json-script
Aug 14, 2026
Merged

fix: further escape JS expressions#227
brillout merged 10 commits into
mainfrom
claude/vike-issue-3463-json-script

Conversation

@brillout

@brillout brillout commented Aug 14, 2026

Copy link
Copy Markdown
Member

Fixes vikejs/vike#3463. Supersedes #226 (closed in favor of this).

Root-cause fix: instead of escaping the store state for a single-quoted JS string literal, it removes the executable string-literal context entirely — with a minimal diff — and carries the independent useConfig() title hardening too, so this is the single complete PR for the issue.

1. Store state → <script type="application/json"> (server-side injection only)

getOrCreateStore() no longer embeds the serialized state inside an executable single-quoted JS string:

-<script>…globalThis._vikeReactZustandState['${key}']='${stringify(state,{htmlScriptSafe:true})}'</script>
+<script type="application/json">${stringify(state,{htmlScriptSafe:true})}</script>
+<script>…globalThis._vikeReactZustandState['${key}']=document.currentScript.previousElementSibling.textContent</script>
  • State lives in the textContent of an inert <script type="application/json"> — never parsed as JavaScript. So the entire class of JS-string-literal escaping (', \, U+2028/9) that caused #3463 simply doesn't exist. The only sensitive character is < (so the HTML parser can't see </script>/<!-- inside the block), and htmlScriptSafe already escapes it transparently for parse().
  • A registrar <script> reads its own data block via previousElementSibling (both tags injected together, always adjacent) at parse time — the same mechanism vike-react-query uses.
  • The client is unchanged. globalThis._vikeReactZustandState[key] is still a key → serialized-state map populated at parse time, and assignServerStateOptional() is byte-for-byte identical to main. All of main's semantics — including client-side-navigation behavior — are preserved; only the transport changes. 6 insertions / 2 deletions in one file.
  • CSP: only the registrar is executable (and thus nonce'd); the inert data block needs no nonce.

key is still interpolated into the registrar (['${key}']), exactly as before — it's developer-controlled (a build-time hash or an explicit create('key') argument), never website-visitor data, so it's not an XSS vector, and it's unchanged from main. The reported vulnerability was entirely in the visitor-controlled state, which is now inert.

2. useConfig() streamed document.title hardening

Independent same-class bug carried over from #226: document.title = ${JSON.stringify(title)} is a valid double-quoted literal, but JSON.stringify() leaves < unescaped, so a title containing </script> breaks out of the inline <script>. Titles are frequently CMS/user-controlled → real XSS vector. Now escapes < (and U+2028/U+2029).

Note

No change is needed in vike itself: its own pageContext/globalContext transfer (used by vike-react-redux) and vike-react-query's hydration already use the safe <script type="application/json"> + textContent pattern. The vulnerable pattern was unique to vike-react-zustand's executable inline <script>.

Verification

  • Real SSR output (preview server), initial to-do list carries a hostile value (Fix O'Brien's bug: escape \ ' " </script> <!-- and \n🚀):
<script type="application/json">{"todoItems":[,{"text":"Fix O'Brien's bug: escape \\ ' \" <\/script> <!-- and \n🚀"}]}</script><script>if(!globalThis._vikeReactZustandState)globalThis._vikeReactZustandState={};globalThis._vikeReactZustandState['ahfbcg']=document.currentScript.previousElementSibling.textContent</script>

The raw ' is now harmless (textContent, not a JS literal), </script> is escaped to <\/script>, and simulating the browser pipeline (run registrars → parse(map[key])) recovers the hostile value exactly.

  • e2e (examples/zustand, both .test-dev and .test-preview pass): asserts the raw hostile string never appears in the SSR HTML and survives SSR serialization + hydration unchanged. The existing "store is persisted upon client-side navigation" test passing confirms behavior parity across SPA navigation.
  • The useConfig title fix is verified to round-trip every case (</script>, <!--, quotes, backslash, U+2028/9) through the JavaScript parser with no literal < remaining.
  • tsc (packages + example) and biome ci clean.

…pplication/json"> (#3463)

Instead of embedding the serialized store state inside an executable,
single-quoted JavaScript string literal — which broke out on a single
quote (e.g. a user name like `O'Brien`), crashing the page and enabling
XSS (#3463) — inject it as the `textContent` of an inert
`<script type="application/json">` block.

Because the payload is never parsed as JavaScript, the whole class of
JS-string-literal escaping disappears: the only sensitive character is
`<` (so the HTML parser can't encounter `</script>`/`<!--` inside the
block), and `htmlScriptSafe` already escapes it transparently for
`parse()`.

An adjacent registrar `<script>` runs synchronously at parse time and
reads its own data block via `previousElementSibling` (the same mechanism
vike-react-query uses), so the client stays exactly as before: the
`globalThis._vikeReactZustandState[key]` map is still populated at parse
time and `assignServerStateOptional()` is unchanged. Only the server-side
injection changes. `key` is developer-controlled (a build-time hash or an
explicit `create('key')` argument), never visitor data, so interpolating
it into the registrar carries no XSS risk — and is unchanged from before.

Adds an e2e regression injecting a hostile store value
(`O'Brien`, `</script>`, `<!--`, backslashes, a newline) and asserting it
never appears raw in the HTML and survives SSR serialization + hydration
unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ziFzKm6QvjuU9yusBCEMU
@brillout
brillout force-pushed the claude/vike-issue-3463-json-script branch from 86bcf9d to 7aff0ff Compare August 14, 2026 10:23
…3463)

`useConfig({ title })`, when applied after <head> has already been sent,
injects `document.title = ${JSON.stringify(title)}` into an inline
<script>. `JSON.stringify()` produces a valid double-quoted JavaScript
string literal (so `"` and `\` are handled), but it does not escape `<`,
so a title containing `</script>` breaks out of the inline <script> —
an XSS vector, since titles are often CMS/user-controlled.

Escape `<` (and U+2028/U+2029) the same way as the store-state injection.

This is the independent hardening that previously lived in the escaper PR;
carrying it here so this PR fully supersedes it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ziFzKm6QvjuU9yusBCEMU
brillout and others added 8 commits August 14, 2026 12:38
Move the inline-<script> escaping (`<` → `<`, U+2028/U+2029) out of
useConfig()'s document.title injection into a reusable
`escapeForHtmlScript()` utility.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ziFzKm6QvjuU9yusBCEMU
…lock

Not strictly required — a data block is never executed, so CSP script-src
doesn't gate it — but vike core adds the nonce to its own
type="application/json" pageContext/globalContext blocks, so match that
for consistency.
@brillout brillout changed the title fix(vike-react-zustand): transfer SSR store state via &lt;script type="application/json"&gt; (#3463) fix: further escape JS expressions Aug 14, 2026
@brillout
brillout merged commit b82c383 into main Aug 14, 2026
7 checks passed
@brillout
brillout deleted the claude/vike-issue-3463-json-script branch August 14, 2026 11:33
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.

vike-react-zustand XSS & crash via unescaped single quotes in SSR serialization

1 participant