Skip to content

fix(vike-react-zustand): escape SSR store state to prevent XSS & crash (#3463) - #226

Closed
brillout wants to merge 1 commit into
mainfrom
claude/vike-issue-3463-h4c0f6
Closed

brillout wants to merge 1 commit into
mainfrom
claude/vike-issue-3463-h4c0f6

Conversation

@brillout

Copy link
Copy Markdown
Member

Fixes vikejs/vike#3463

Problem

getOrCreateStore() embeds the serialized store state inside a single-quoted JavaScript string literal of an inline <script>, without escaping it:

`...globalThis._vikeReactZustandState['${key}']='${stringify(transferableState, { htmlScriptSafe: true })}'</script>`

A single quote in any store value (e.g. a user name like O'Brien) breaks out of the string literal and crashes the page:

Uncaught SyntaxError: unexpected token: identifier

A crafted value can execute arbitrary JavaScript (XSS).

htmlScriptSafe from @brillout/json-serializer only escapes < and / — it deliberately does not escape ', because the correct escaping depends on the wrapping context. The state is wrapped in a single-quoted literal, so ' and \ must be escaped too. That's a fact only the injection site knows, so escaping belongs here rather than in the serializer (which is also used for the double-quoted / type="application/json" cases where a different escaping applies).

Fix

Escape the key and the serialized state for the single-quoted JS string context via a small, dependency-free helper escapeForJsSingleQuotedString():

  • \\\ and '\' — the actual break-out / crash fix
  • <\u003c — so the HTML parser never sees </script> or <!-- inside the inline <script> (the JS parser decodes it back to <)
  • \n, \r, U+2028, U+2029 → escaped — a raw line terminator inside a JS string literal is a syntax error (U+2028/U+2029 in pre-ES2019 browsers)

The value still round-trips: the browser's JS parser decodes the single-quoted literal back to the exact stringify() output, which parse() then deserializes unchanged.

Bonus hardening (vike-react)

useConfig()'s streamed document.title injection (useConfig-server.ts) has the same class of bug. JSON.stringify(title) produces a valid double-quoted literal (so " and \ are already handled), but it leaves < unescaped — so a title containing </script> breaks out of the inline <script>. Titles are frequently CMS/user-controlled, so this is a real XSS vector. Escaped < (plus U+2028/U+2029). This hunk is self-contained and can be dropped if you'd prefer to keep the PR strictly zustand-scoped.

Note

No change is needed in vike itself. Vike's own pageContext/globalContext transfer (also used by vike-react-redux) and vike-react-query's hydration both use the safe <script type="application/json"> + textContent pattern, where the payload is never parsed as a JS string literal. The vulnerable pattern was unique to vike-react-zustand's executable single-quoted inline <script>.

Tests

  • Unit (escapeForJsSingleQuotedString.spec.ts, new test:units script for the package):
    • round-trips tricky strings (O'Brien, break-out payloads, backslashes, </script>, <!--, newlines, U+2028/U+2029, emoji) through the actual JavaScript parser
    • asserts the escaped output never contains <, an unescaped ', or a raw line terminator
    • 1000-iteration deterministic fuzz over a hostile alphabet
    • full simulation: stringify() → inline <script> → JS parser → parse() equals the original state (objects, Map, Set, Date, nested single quotes)
  • e2e (examples/zustand): the initial to-do list now contains a hostile value (Fix O'Brien's bug: escape \ ' " </script> <!-- and \n🚀); the test asserts the raw string never appears in the SSR HTML and that it survives SSR serialization + hydration unchanged. Both .test-dev and .test-preview pass.

Before / after (real SSR output, verified locally)

Before — the injected script is invalid JS and throws SyntaxError: unexpected token 'Brien'.

After:

<script>...globalThis._vikeReactZustandState['ahfbcg']='{"todoItems":[...,{"text":"Fix O\'Brien\'s bug: escape \\\\ \' \\" \\u003c\\/script> \\u003c!-- and \\n🚀"}]}'</script>

No literal <, quotes escaped, and parse() recovers the exact original value.


Generated by Claude Code

…h (#3463)

`getOrCreateStore()` embedded the serialized store state inside a
single-quoted JavaScript string literal of an inline `<script>` without
escaping it. A single quote in a store value (e.g. a user name like
`O'Brien`) broke out of the string literal, crashing the page with a
`SyntaxError`, and a crafted value could execute arbitrary JavaScript.

`htmlScriptSafe` only escapes `<` and `/`; it deliberately does not
escape `'`, because the correct escaping depends on the wrapping context
(here: a single-quoted literal) which only the injection site knows. Fix
it at the injection site by escaping the key and the serialized state for
the single-quoted string context (`\`, `'`, `<`, and line terminators)
via a new `escapeForJsSingleQuotedString()` helper.

Also harden `vike-react`'s `useConfig()` streamed `document.title`
injection against the same class of bug: `JSON.stringify()` yields a
valid double-quoted literal but leaves `<` unescaped, so a title
containing `</script>` could break out of the inline `<script>`.

Add unit tests (round-trip through the JavaScript parser, hostile-string
fuzzing, and a full stringify => inline script => parse simulation) and
an e2e regression covering a hostile store value.

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

Copy link
Copy Markdown
Member Author

Superseded by #227 and closing in favor of it.

#227 fixes vikejs/vike#3463 at the root instead of escaping around it: the store state is transferred as the textContent of an inert <script type="application/json"> block (read on the client via previousElementSibling), so it's never parsed as JavaScript and the whole class of JS-string-literal escaping — including the escapeForJsSingleQuotedString helper this PR added — disappears. The client contract is unchanged, so it's a 6-insertion/2-deletion transport change.

#227 also carries this PR's independent useConfig() document.title <-escaping hardening, so nothing here is lost.


Generated by Claude Code

@brillout brillout closed this Aug 14, 2026
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