fix(builder): sanitize CSS-value sinks to prevent style/<style> breakout XSS

Adds a single cssValue() sanitizer (src/utils/escape.ts) that strips
<>{};"'\ and neutralizes url(), safe for both style="..." attribute and
<style>...</style> element contexts. Applies it at every raw user-prop
CSS-value interpolation sink found via grep across src/components (colors,
sizes, gaps interpolated directly into style strings/<style> blocks),
including the highest-risk <style>-context sinks: ColumnLayout gap,
Menu/Navbar hover and background colors. Also Number()-coerces the
`columns` grid-template-columns sinks in Gallery/Testimonials/NumberCounter
as defense in depth. Regression tests assert </style><script> payloads are
neutralized and normal colors still render.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-12 15:56:25 -07:00
parent 5acf172511
commit 36c3b2f503
24 changed files with 318 additions and 70 deletions
+24
View File
@@ -79,6 +79,30 @@ export function safeUrl(s: string): string {
return trimmed;
}
/**
* Neutralizes CSS-context breakout for a single design-token value (color,
* length, gradient, etc.) so it is safe to interpolate RAW into either CSS
* context found in exported HTML:
* - ATTRIBUTE context: `style="color:${v}"` -- a `"` or `'` closes the
* attribute early; a `;` injects an extra declaration.
* - ELEMENT context: `<style>.x{color:${v}}</style>` -- `<`, `>`, `{`, `}`
* let a value close the rule / the `<style>` element itself and open a
* `<script>`, which is the worst case (arbitrary script execution).
* Strips the characters that can terminate a value, declaration, rule, or
* the `<style>` element itself, and neutralizes `url(` so a background
* shorthand can't smuggle a `javascript:` URL. None of hex colors,
* `rgb()`/`rgba()`, `var(...)`, gradients, `calc(...)`, or `px`/`%`/`em`/
* `rem` sizes contain any stripped character, so legitimate values pass
* through unchanged. If a sink legitimately needs a background image URL,
* route that value through `safeUrl` instead of `cssValue`.
*/
export function cssValue(v: unknown): string {
if (typeof v !== 'string') return '';
return v
.replace(/[<>{};"'\\]/g, '')
.replace(/url\s*\(/gi, '');
}
/**
* Derives a deterministic, HTML-id-safe slug from a field's `name`/`label`
* for wiring `<label for>` to a matching `<input id>` in exported markup.