Skip to content

fix(security): encode values in inline <script>/<style> sinks to close XSS class - #507

Open
0xcucumbersalad wants to merge 1 commit into
mainfrom
fix/xss-script-sink-encoding
Open

fix(security): encode values in inline <script>/<style> sinks to close XSS class#507
0xcucumbersalad wants to merge 1 commit into
mainfrom
fix/xss-script-sink-encoding

Conversation

@0xcucumbersalad

@0xcucumbersalad 0xcucumbersalad commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Summary

Framework components emit CMS/loader-configurable values into inline <script>/<style> bodies via dangerouslySetInnerHTML using bare JSON.stringify / raw interpolation. Inside a <script>/<style> the HTML parser (not the JSON/JS/CSS grammar) decides where the element ends, so a value containing </script>/</style> breaks out of the tag and injects markup. React does not escape dangerouslySetInnerHTML.

Reachable two ways:

  • Stored — a value set through normal CMS content renders on the live page.
  • Reflected — the unauthenticated /deco/render preview endpoint renders any registered section with attacker-supplied props (GET or POST), reflecting the payload into text/html.

Fix

Context-aware output encoding at the sink (the class-correct fix — one chokepoint per output context, works for both stored and reflected paths, doesn't break legit inline scripts).

New @decocms/blocks/sdk/htmlSafe:

  • htmlSafeJson — JSON embedded in <script> (<,>,&, U+2028/2029 → \uXXXX; still valid JSON)
  • jsString — value inside a quoted JS string literal
  • cssSafe — value inside a <style> body (</style> breakout)

Wired into the sinks:

Package Sink Encoder
blocks hooks/JsonLd (ld+json) htmlSafeJson
blocks hooks/LiveControls (__DECO_STATE) htmlSafeJson
blocks sdk/useScript (args) htmlSafeJson
apps-website components/Seo (SeoV2 jsonLDs) htmlSafeJson
apps-website components/Theme (design tokens + font styleSheet) cssSafe
tanstack hooks/DraftPreviewIndicator htmlSafeJson (defense-in-depth; pointer already charset-constrained upstream)
tanstack sdk/speculationRules htmlSafeJson
nextjs DecoRootLayout (events bootstrap account) htmlSafeJson
blocks-cli Seo scaffold template emits htmlSafeJson

Not changed (assessed)

GoogleTagManager — its trackingId flows through new URL().href, which percent-encodes quotes/</> (verified: '%27), so it cannot break out of the inline JS string. GTAG additionally sanitizes.

Tests

  • sdk/htmlSafe.test.ts — encoder unit tests (breakout neutralized, JSON still round-trips).
  • hooks/xssSinks.test.tsx, apps-website/.../xssSinks.test.tsx, sdk/useScript.test.ts — rendered-output regressions per sink.
  • sdk/scriptSinkGuard.test.tsrepo-wide guard: fails CI if any dangerouslySetInnerHTML uses a bare JSON.stringify. Keeps the class closed as new sinks land.

All new tests pass; typecheck clean for the new cross-package imports. Pre-existing suite failures (URLPattern/matchPath, layoutCacheRace, crypto, nextjs draft) reproduce identically on main and are unrelated.

Note

The /deco/render preview-shell eval origin check is intentionally not in this PR — it is handled separately.


Summary by cubic

Fixes an XSS class where CMS-configurable values rendered into inline <script>/<style> bodies could break out of the tag and inject markup. Values are now escaped at the sink by context-aware encoders in @decocms/blocks/sdk/htmlSafe; legitimate data still round-trips unchanged.

Bug Fixes

  • htmlSafeJson escapes JSON embedded in <script>, jsString escapes quoted JS string literals, and cssSafe escapes <style> bodies.
  • Applied to the JsonLd, LiveControls, useScript, Seo, Theme, DraftPreviewIndicator, speculationRules, and DecoRootLayout sinks plus the blocks-cli Seo scaffold, covering both stored CMS content and the /deco/render preview endpoint.
  • GoogleTagManager stays unchanged — its trackingId flows through new URL().href, which percent-encodes quotes and angle brackets.

Tests

  • scriptSinkGuard.test.ts fails CI if any dangerouslySetInnerHTML uses a bare JSON.stringify.
  • Each sink gets a rendered-output regression test; encoder output still parses back to the original values.

Written for commit 9a7ace9. Summary will update on new commits.

Review in cubic

…e XSS class

Framework components emit CMS/loader-configurable values into inline
<script>/<style> bodies via dangerouslySetInnerHTML using bare
JSON.stringify / raw interpolation. Inside a <script>/<style> the HTML
parser (not the JSON/JS/CSS grammar) decides where the element ends, so a
value containing `</script>`/`</style>` breaks out of the tag and injects
markup. React does not escape dangerouslySetInnerHTML. Reachable both via
stored CMS content (live page) and reflected via the unauthenticated
/deco/render preview endpoint.

Add context-aware encoders in @decocms/blocks/sdk/htmlSafe:
- htmlSafeJson  — JSON embedded in <script> (<,>,& and U+2028/2029 -> \\uXXXX)
- jsString      — value in a quoted JS string literal
- cssSafe       — value in a <style> body (</style> breakout)

Wire them into the sinks:
- blocks: hooks/JsonLd (ld+json), hooks/LiveControls (__DECO_STATE),
  sdk/useScript (args)
- apps-website: components/Seo (SeoV2 jsonLDs), components/Theme
  (design tokens + font styleSheet)
- tanstack: hooks/DraftPreviewIndicator (defense-in-depth; the draft
  pointer is already charset-constrained upstream), sdk/speculationRules
- nextjs: DecoRootLayout (events bootstrap account)
- blocks-cli: Seo scaffold template now emits htmlSafeJson

Add scriptSinkGuard.test.ts: repo-wide guard that fails if any
dangerouslySetInnerHTML uses a bare JSON.stringify, plus rendered-output
regression tests per sink.

GoogleTagManager was assessed and left unchanged: its trackingId flows
through new URL().href, which percent-encodes quotes/<>/ (verified), so it
cannot break out; GTAG additionally sanitizes.
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.

1 participant