Conversation
…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
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 #227 also carries this PR's independent Generated by Claude Code |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:A crafted value can execute arbitrary JavaScript (XSS).
htmlScriptSafefrom@brillout/json-serializeronly 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, whichparse()then deserializes unchanged.Bonus hardening (
vike-react)useConfig()'s streameddocument.titleinjection (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
vikeitself. Vike's ownpageContext/globalContexttransfer (also used byvike-react-redux) andvike-react-query's hydration both use the safe<script type="application/json">+textContentpattern, where the payload is never parsed as a JS string literal. The vulnerable pattern was unique tovike-react-zustand's executable single-quoted inline<script>.Tests
escapeForJsSingleQuotedString.spec.ts, newtest:unitsscript for the package):O'Brien, break-out payloads, backslashes,</script>,<!--, newlines, U+2028/U+2029, emoji) through the actual JavaScript parser<, an unescaped', or a raw line terminatorstringify()→ inline<script>→ JS parser →parse()equals the original state (objects,Map,Set,Date, nested single quotes)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-devand.test-previewpass.Before / after (real SSR output, verified locally)
Before — the injected script is invalid JS and throws
SyntaxError: unexpected token 'Brien'.After:
No literal
<, quotes escaped, andparse()recovers the exact original value.Generated by Claude Code