Skip to content

fix(security): hardened CSP on /deco/render (reflected XSS execution vector) - #509

Open
0xcucumbersalad wants to merge 1 commit into
mainfrom
fix/deco-render-csp-hardening
Open

fix(security): hardened CSP on /deco/render (reflected XSS execution vector)#509
0xcucumbersalad wants to merge 1 commit into
mainfrom
fix/deco-render-csp-hardening

Conversation

@0xcucumbersalad

@0xcucumbersalad 0xcucumbersalad commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Problem

/deco/render (handleRender) renders any registered section with fully caller-controlled props as text/html, unauthenticated — wrapped only in withCors/withTracing. A caller-controlled rich-text prop that reaches an HTML sink (dangerouslySetInnerHTML) is reflected XSS, delivered via:

GET /deco/render?resolveChain=site/sections/<Section>.tsx&props=<json>

The render response carried no CSP, and deco's enforced CSP still ships script-src 'unsafe-inline' (the strict policy is report-only), so an injected <img src=x onerror=alert(document.domain)> executes in the victim's browser.

This was surfaced auditing a downstream storefront: 7 confirmed raw section sinks (marquees[].text, title-box rich text, …) reachable through /deco/render with no sanitizer.

Scope decision

  • /deco/render only. /deco/invoke is deliberately not touched — it is the storefronts' runtime RPC (lazy sections, cart actions, search) called from public browsers and returns JSON, not HTML. Blanket-gating it would break every live site; it is not the XSS delivery route.
  • No auth gate. The studio iframe loads /deco/render cross-origin with no credential today, so a prod auth gate would break admin preview everywhere pending cross-repo coordination. This PR ships the execution-layer mitigation that needs zero coordination and closes the vector regardless of which section sink exists.

What changed

  • blocks/sdk/csp.tsbuildRenderCSP({ nonce, adminOrigins }): default-src 'none'; script-src 'nonce-<per-response>' (no unsafe-inline → inline onerror/onload attributes and un-nonced <script> are blocked = the payload is neutralized); base-uri/form-action 'none'; admin-only frame-ancestors (also closes clickjacking). Non-script directives (img/style/font) stay permissive so the preview still paints. Plus generateCSPNonce() (128-bit Web Crypto, fresh per response).
  • blocks-admin/sdk/htmlShell.ts — threads a nonce onto the framework's inline preview <script> so LIVE_CONTROLS_SCRIPT survives the nonce policy.
  • blocks-admin/admin/render.ts — one nonce per response; CSP + X-Content-Type-Options: nosniff stamped on every path (section, page, unknown, error) via a shared htmlResponse helper.
  • blocks-admin/admin/cors.ts — exports getAdminOrigins() for the real studio origin set.

withCors copies existing response headers before adding CORS, so the CSP survives both the TanStack and Next.js adapters.

Tests

  • blocks/sdk/csp.test.ts — builder directives + nonce freshness.
  • blocks-admin/admin/render.csp.test.ts — payload is reflected (proves the sink) but the response carries the neutralizing policy; framework script nonce matches the CSP; per-response nonce freshness; error path also stamped.
  • 21 pass; existing resolvePreview.test.ts unchanged (no regression).

Not in scope / follow-ups

  • Complements fix(security): encode values in inline <script>/<style> sinks to close XSS class #507 (script/style JSON.stringify breakout via htmlSafe*) — orthogonal flavor.
  • Site-level defense-in-depth remains: sanitizeHtml each rich-text sink.
  • Under a nonce policy, section-injected inline <script> (carousels, etc.) is also blocked in preview — acceptable trade for preview; noted for follow-up if any admin-preview interactivity regresses.

🤖 Generated with Claude Code


Summary by cubic

Hardens the CSP on /deco/render so reflected XSS payloads from caller-controlled section props can no longer execute. The endpoint previously returned HTML with no CSP, letting injected inline handlers like <img onerror=...> run in the victim's browser; every response now carries a nonce-based CSP that blocks them.

Details

  • Stamps a per-response nonce script-src (no unsafe-inline) with X-Content-Type-Options: nosniff on every path, including errors, and tags the framework's own preview script with the same nonce so it keeps working.
  • Restricts frame-ancestors to admin origins, which also closes clickjacking.
  • Leaves /deco/invoke untouched since it returns JSON and gating it would break live storefronts; no auth gate was added to avoid breaking the studio's cross-origin iframe.
  • Side effect: section-injected inline scripts no longer run in preview; sanitizeHtml on rich-text sinks remains the site-level follow-up.

Written for commit ec4c69b. Summary will update on new commits.

Review in cubic

…ed XSS)

`/deco/render` renders any registered section with fully caller-controlled
props as text/html, unauthenticated. A rich-text prop reaching an HTML sink
(`dangerouslySetInnerHTML`) is reflected XSS delivered via
`GET /deco/render?resolveChain=...&props=...`. The response carried no CSP, and
deco's enforced CSP still ships `script-src 'unsafe-inline'`, so an injected
`<img onerror>` executes.

Add an execution-layer mitigation on the render response only (leaves the
`/deco/invoke` runtime RPC untouched — it is not preview-only and returns JSON):

- `buildRenderCSP({ nonce, adminOrigins })` in `blocks/sdk/csp`: a locked-down
  policy — `default-src 'none'`, `script-src 'nonce-<per-response>'` (no
  `unsafe-inline`, so inline `onerror`/`onload` attributes and un-nonced
  `<script>` are blocked), `base-uri`/`form-action` `'none'`, admin-only
  `frame-ancestors` (also closes clickjacking). Non-script directives stay
  permissive so the preview still paints.
- `generateCSPNonce()`: 128-bit Web Crypto nonce, fresh per response.
- `htmlShell` threads a `nonce` onto the framework's inline preview script so
  `LIVE_CONTROLS_SCRIPT` survives the nonce policy.
- `handleRender` generates one nonce per response and stamps the CSP +
  `X-Content-Type-Options: nosniff` on every path (section, page, unknown,
  error) via a shared `htmlResponse` helper.
- `getAdminOrigins()` exported from `cors` for the real studio origin set.

Defense-in-depth: the site-level fix is still to `sanitizeHtml` each rich-text
sink. This closes the execution vector framework-wide regardless.

Tests: csp.test.ts (builder + nonce), render.csp.test.ts (payload reflected but
inert, nonce match, per-response freshness, error path). 21 pass; existing
resolvePreview.test.ts unchanged.
@0xcucumbersalad
0xcucumbersalad requested a review from a team August 27, 2026 16:50
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