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:
@@ -0,0 +1,78 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { cssValue } from './escape';
|
||||
|
||||
describe('cssValue', () => {
|
||||
test('neutralizes </style> breakout', () => {
|
||||
expect(cssValue('0px)}</style><script>alert(1)</script><style>{')).not.toContain('</style');
|
||||
expect(cssValue('0px)}</style><script>alert(1)</script><style>{')).not.toContain('<script');
|
||||
});
|
||||
|
||||
test('strips double quote (attribute breakout)', () => {
|
||||
expect(cssValue('red" onmouseover="alert(1)')).not.toContain('"');
|
||||
});
|
||||
|
||||
test('strips single quote', () => {
|
||||
expect(cssValue("red' onmouseover='alert(1)")).not.toContain("'");
|
||||
});
|
||||
|
||||
test('strips semicolon (declaration injection)', () => {
|
||||
expect(cssValue('red;background:url(javascript:alert(1))')).not.toContain(';');
|
||||
});
|
||||
|
||||
test('strips curly braces (rule injection)', () => {
|
||||
expect(cssValue('red}body{background:red')).not.toContain('{');
|
||||
expect(cssValue('red}body{background:red')).not.toContain('}');
|
||||
});
|
||||
|
||||
test('strips angle brackets', () => {
|
||||
expect(cssValue('<b>red</b>')).not.toContain('<');
|
||||
expect(cssValue('<b>red</b>')).not.toContain('>');
|
||||
});
|
||||
|
||||
test('strips backslash', () => {
|
||||
expect(cssValue('red\\3c script')).not.toContain('\\');
|
||||
});
|
||||
|
||||
test('neutralizes url( to block javascript: via background shorthand', () => {
|
||||
expect(cssValue("red;background:url(javascript:alert(1))")).not.toMatch(/url\s*\(/i);
|
||||
});
|
||||
|
||||
test('non-string input returns empty string', () => {
|
||||
expect(cssValue(undefined)).toBe('');
|
||||
expect(cssValue(null)).toBe('');
|
||||
expect(cssValue(123 as unknown)).toBe('');
|
||||
expect(cssValue({} as unknown)).toBe('');
|
||||
});
|
||||
|
||||
test('preserves hex colors', () => {
|
||||
expect(cssValue('#fff')).toBe('#fff');
|
||||
expect(cssValue('#3b82f6')).toBe('#3b82f6');
|
||||
});
|
||||
|
||||
test('preserves rgba()', () => {
|
||||
expect(cssValue('rgba(0,0,0,.5)')).toBe('rgba(0,0,0,.5)');
|
||||
});
|
||||
|
||||
test('preserves linear-gradient()', () => {
|
||||
expect(cssValue('linear-gradient(90deg,#aaa 0%,#bbb 100%)')).toBe('linear-gradient(90deg,#aaa 0%,#bbb 100%)');
|
||||
});
|
||||
|
||||
test('preserves calc()', () => {
|
||||
expect(cssValue('calc(50% - 8px)')).toBe('calc(50% - 8px)');
|
||||
});
|
||||
|
||||
test('preserves var()', () => {
|
||||
expect(cssValue('var(--x)')).toBe('var(--x)');
|
||||
});
|
||||
|
||||
test('preserves border shorthand', () => {
|
||||
expect(cssValue('1px solid #ccc')).toBe('1px solid #ccc');
|
||||
});
|
||||
|
||||
test('preserves px/%/em/rem sizes', () => {
|
||||
expect(cssValue('24px')).toBe('24px');
|
||||
expect(cssValue('50%')).toBe('50%');
|
||||
expect(cssValue('1.5em')).toBe('1.5em');
|
||||
expect(cssValue('2rem')).toBe('2rem');
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user