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
co-authored by Claude Opus 4.8
parent 5acf172511
commit 36c3b2f503
24 changed files with 318 additions and 70 deletions
@@ -59,3 +59,24 @@ describe('ColumnLayout.toHtml deterministic + unique scope ids (thread node id,
expect(html1).toBe(html2);
});
});
describe('ColumnLayout.toHtml XSS hardening (gap into <style>)', () => {
test('a gap value containing </style><script> is neutralized in the <style>-context nth-child rule', () => {
const malicious = '0px)}</style><script>alert(1)</script><style>{';
const { html } = toHtml({ columns: 2, split: '50-50', gap: malicious }, '<div>A</div><div>B</div>', 'node-xss');
expect(html).not.toContain('</style><script');
expect(html).not.toContain('<script>alert(1)</script>');
});
test('a gap value containing a quote/semicolon breakout is neutralized in the style attribute', () => {
const malicious = '16px" onmouseover="alert(1)';
const { html } = toHtml({ columns: 2, split: '50-50', gap: malicious }, '', 'node-xss2');
expect(html).not.toMatch(/style="[^"]*"[^>]*onmouseover/);
});
test('a normal gap value still renders correctly', () => {
const { html } = toHtml({ columns: 2, split: '50-50', gap: '24px' }, '<div>A</div>', 'node-normal');
expect(html).toContain('gap:24px');
expect(html).toMatch(/calc\(50% - 24px\)/);
});
});
+6 -2
View File
@@ -2,7 +2,7 @@ import React, { CSSProperties } from 'react';
import { useNode, Element, UserComponent } from '@craftjs/core';
import { Container } from './Container';
import { cssPropsToString } from '../../utils/style-helpers';
import { escapeAttr, scopeId } from '../../utils/escape';
import { escapeAttr, scopeId, cssValue } from '../../utils/escape';
type SplitOption =
| '100'
@@ -117,7 +117,11 @@ ColumnLayout.craft = {
(ColumnLayout as any).toHtml = (props: ColumnLayoutProps, childrenHtml: string, nodeId?: string) => {
const columns = props.columns || 2;
const split = props.split || '50-50';
const gap = props.gap || '16px';
// Sanitized once here so BOTH the raw <style> nth-child rule below AND the
// cssPropsToString-built outerStyle get a safe value -- gap is a raw
// string-interpolation sink into a <style> block (worst case: </style>
// breakout -> arbitrary <script>), see task-cssxss-brief.md.
const gap = cssValue(props.gap) || '16px';
const widths = getWidths(split, columns);
const outerStyle = cssPropsToString({
+3 -2
View File
@@ -2,7 +2,7 @@ import React, { CSSProperties } from 'react';
import { useNode, Element, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { Container } from './Container';
import { escapeAttr } from '../../utils/escape';
import { escapeAttr, cssValue } from '../../utils/escape';
/* ---------- Shape Divider SVG Paths ---------- */
@@ -167,7 +167,8 @@ function buildDividerHtml(
const isTop = position === 'top';
const h = height || '50px';
const c = color || '#ffffff';
// Sanitized -- raw string-interpolation sink in the SVG `fill:${c}` below.
const c = cssValue(color) || '#ffffff';
const wrapperStyle = cssPropsToString({
position: 'absolute',