fix: further escape JS expressions - #227
Merged
Merged
Conversation
…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
force-pushed
the
claude/vike-issue-3463-json-script
branch
from
August 14, 2026 10:23
86bcf9d to
7aff0ff
Compare
…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
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.
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. 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:textContentof 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), andhtmlScriptSafealready escapes it transparently forparse().<script>reads its own data block viapreviousElementSibling(both tags injected together, always adjacent) at parse time — the same mechanismvike-react-queryuses.globalThis._vikeReactZustandState[key]is still akey → serialized-statemap populated at parse time, andassignServerStateOptional()is byte-for-byte identical tomain. All of main's semantics — including client-side-navigation behavior — are preserved; only the transport changes. 6 insertions / 2 deletions in one file.keyis still interpolated into the registrar (['${key}']), exactly as before — it's developer-controlled (a build-time hash or an explicitcreate('key')argument), never website-visitor data, so it's not an XSS vector, and it's unchanged frommain. The reported vulnerability was entirely in the visitor-controlled state, which is now inert.2.
useConfig()streameddocument.titlehardeningIndependent same-class bug carried over from #226:
document.title = ${JSON.stringify(title)}is a valid double-quoted literal, butJSON.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
vikeitself: its ownpageContext/globalContexttransfer (used byvike-react-redux) andvike-react-query's hydration already use the safe<script type="application/json">+textContentpattern. The vulnerable pattern was unique tovike-react-zustand's executable inline<script>.Verification
Fix O'Brien's bug: escape \ ' " </script> <!-- and \n🚀):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.examples/zustand, both.test-devand.test-previewpass): 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.useConfigtitle 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) andbiome ciclean.