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:
@@ -30,3 +30,17 @@ describe('Menu.toHtml deterministic + unique scope ids (thread node id, no Math.
|
||||
expect(html1).toBe(html2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Menu.toHtml XSS hardening (linkHoverColor into <style>)', () => {
|
||||
test('a linkHoverColor value containing </style><script> is neutralized', () => {
|
||||
const malicious = '#fff}</style><script>alert(1)</script><style>{';
|
||||
const { html } = toHtml({ linkHoverColor: malicious }, '', 'node-xss');
|
||||
expect(html).not.toContain('</style><script');
|
||||
expect(html).not.toContain('<script>alert(1)</script>');
|
||||
});
|
||||
|
||||
test('a normal linkHoverColor still renders in the hover rule', () => {
|
||||
const { html } = toHtml({ linkHoverColor: '#ff0000' }, '', 'node-normal');
|
||||
expect(html).toMatch(/:hover\s*\{\s*color:\s*#ff0000/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { CSSProperties, useState } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { escapeHtml, escapeAttr, safeUrl, scopeId } from '../../utils/escape';
|
||||
import { escapeHtml, escapeAttr, safeUrl, scopeId, cssValue } from '../../utils/escape';
|
||||
|
||||
/* ---------- Types ---------- */
|
||||
|
||||
@@ -125,14 +125,18 @@ Menu.craft = {
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(Menu as any).toHtml = (props: MenuProps, _childrenHtml: string, nodeId?: string) => {
|
||||
const linkCol = props.linkColor || '#3f3f46';
|
||||
const hoverCol = props.linkHoverColor || '#3b82f6';
|
||||
const ctaBg = props.ctaBgColor || '#3b82f6';
|
||||
const ctaText = props.ctaTextColor || '#ffffff';
|
||||
const gap = props.gap || '24px';
|
||||
// Sanitized once here -- linkCol/hoverCol/ctaBg/ctaText/gap/fSize are raw
|
||||
// string-interpolation sinks below (hoverCol goes into a <style> block,
|
||||
// the worst case: </style> breakout -> arbitrary <script>), see
|
||||
// task-cssxss-brief.md.
|
||||
const linkCol = cssValue(props.linkColor) || '#3f3f46';
|
||||
const hoverCol = cssValue(props.linkHoverColor) || '#3b82f6';
|
||||
const ctaBg = cssValue(props.ctaBgColor) || '#3b82f6';
|
||||
const ctaText = cssValue(props.ctaTextColor) || '#ffffff';
|
||||
const gap = cssValue(props.gap) || '24px';
|
||||
const orientation = props.orientation || 'horizontal';
|
||||
const alignment = props.alignment || 'right';
|
||||
const fSize = props.fontSize || '14px';
|
||||
const fSize = cssValue(props.fontSize) || '14px';
|
||||
|
||||
const justifyMap: Record<string, string> = { left: 'flex-start', center: 'center', right: 'flex-end' };
|
||||
|
||||
|
||||
@@ -26,3 +26,38 @@ describe('Navbar.toHtml hamburger accessibility (F2.3)', () => {
|
||||
expect(html).not.toContain('navbar-hamburger');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Navbar.toHtml XSS hardening (hoverColor/backgroundColor/ctaColor into <style>)', () => {
|
||||
test('a hoverColor value containing </style><script> is neutralized in the hover <style> block', () => {
|
||||
const malicious = '#fff}</style><script>alert(1)</script><style>{';
|
||||
const { html } = toHtml({ hoverColor: malicious }, '');
|
||||
expect(html).not.toContain('</style><script');
|
||||
expect(html).not.toContain('<script>alert(1)</script>');
|
||||
});
|
||||
|
||||
test('a backgroundColor value containing </style><script> is neutralized (mobile media-query rule)', () => {
|
||||
const malicious = '#fff}</style><script>alert(2)</script><style>{';
|
||||
const { html } = toHtml({ backgroundColor: malicious, showMobileMenu: true }, '');
|
||||
expect(html).not.toContain('</style><script');
|
||||
expect(html).not.toContain('<script>alert(2)</script>');
|
||||
});
|
||||
|
||||
test('a ctaColor value containing </style><script> is neutralized', () => {
|
||||
const malicious = '#fff}</style><script>alert(3)</script><style>{';
|
||||
const { html } = toHtml({ ctaColor: malicious }, '');
|
||||
expect(html).not.toContain('</style><script');
|
||||
expect(html).not.toContain('<script>alert(3)</script>');
|
||||
});
|
||||
|
||||
test('a textColor value containing a quote breakout does not escape the hamburger span style attribute', () => {
|
||||
const malicious = '#333" onmouseover="alert(1)';
|
||||
const { html } = toHtml({ textColor: malicious, showMobileMenu: true }, '');
|
||||
expect(html).not.toMatch(/style="[^"]*"[^>]*onmouseover/);
|
||||
});
|
||||
|
||||
test('normal colors still render correctly', () => {
|
||||
const { html } = toHtml({ hoverColor: '#ff0000', backgroundColor: '#123456', ctaColor: '#00ff00' }, '');
|
||||
expect(html).toMatch(/:hover\s*\{\s*color:\s*#ff0000/);
|
||||
expect(html).toContain('background-color:#123456');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { CSSProperties, useState } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { useSiteDesign } from '../../state/SiteDesignContext';
|
||||
import { escapeHtml, escapeAttr, safeUrl } from '../../utils/escape';
|
||||
import { escapeHtml, escapeAttr, safeUrl, cssValue } from '../../utils/escape';
|
||||
|
||||
/* ---------- Types ---------- */
|
||||
|
||||
@@ -211,12 +211,15 @@ Navbar.craft = {
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(Navbar as any).toHtml = (props: NavbarProps, _childrenHtml: string) => {
|
||||
const bgColor = props.backgroundColor || '#ffffff';
|
||||
const textCol = props.textColor || '#3f3f46';
|
||||
const hoverCol = props.hoverColor || '#3b82f6';
|
||||
const ctaCol = props.ctaColor || '#3b82f6';
|
||||
const ctaTextCol = props.ctaTextColor || '#ffffff';
|
||||
const pad = props.padding || '16px 24px';
|
||||
// Sanitized once here -- these are raw string-interpolation sinks below
|
||||
// (hoverCol/bgColor go into a <style> block, the worst case: </style>
|
||||
// breakout -> arbitrary <script>), see task-cssxss-brief.md.
|
||||
const bgColor = cssValue(props.backgroundColor) || '#ffffff';
|
||||
const textCol = cssValue(props.textColor) || '#3f3f46';
|
||||
const hoverCol = cssValue(props.hoverColor) || '#3b82f6';
|
||||
const ctaCol = cssValue(props.ctaColor) || '#3b82f6';
|
||||
const ctaTextCol = cssValue(props.ctaTextColor) || '#ffffff';
|
||||
const pad = cssValue(props.padding) || '16px 24px';
|
||||
const alignment = props.navAlignment || 'space-between';
|
||||
const sticky = props.isSticky;
|
||||
const mobile = props.showMobileMenu;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { escapeAttr, safeUrl } from '../../utils/escape';
|
||||
import { escapeAttr, safeUrl, cssValue } from '../../utils/escape';
|
||||
|
||||
interface SocialLink {
|
||||
platform: string;
|
||||
@@ -163,9 +163,10 @@ SocialLinks.craft = {
|
||||
|
||||
(SocialLinks as any).toHtml = (props: SocialLinksProps, _childrenHtml: string) => {
|
||||
const links = props.links || defaultLinks;
|
||||
const iconSize = props.iconSize || '20px';
|
||||
const iconColor = props.iconColor || '#ffffff';
|
||||
const iconBgColor = props.iconBgColor || '#374151';
|
||||
// Sanitized -- raw string-interpolation sinks in aStyle/getShapeStr below.
|
||||
const iconSize = cssValue(props.iconSize) || '20px';
|
||||
const iconColor = cssValue(props.iconColor) || '#ffffff';
|
||||
const iconBgColor = cssValue(props.iconBgColor) || '#374151';
|
||||
const iconShape = props.iconShape || 'circle';
|
||||
const gap = props.gap || '10px';
|
||||
const alignment = props.alignment || 'center';
|
||||
|
||||
@@ -21,3 +21,22 @@ describe('StarRating.toHtml accessibility (F2.2)', () => {
|
||||
expect(html).toContain('aria-label="Rating: 2 out of 10"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('StarRating.toHtml XSS hardening (filledColor/emptyColor/size into style=)', () => {
|
||||
test('a filledColor value containing a quote breakout is neutralized', () => {
|
||||
const malicious = '#f00" onmouseover="alert(1)';
|
||||
const { html } = toHtml({ rating: 3, maxStars: 5, filledColor: malicious }, '');
|
||||
expect(html).not.toMatch(/style="[^"]*"[^>]*onmouseover/);
|
||||
});
|
||||
|
||||
test('a size value containing </style><script> is neutralized', () => {
|
||||
const malicious = '24px</style><script>alert(1)</script>';
|
||||
const { html } = toHtml({ rating: 3, maxStars: 5, size: malicious }, '');
|
||||
expect(html).not.toContain('<script>alert(1)</script>');
|
||||
});
|
||||
|
||||
test('a normal filled color still renders', () => {
|
||||
const { html } = toHtml({ rating: 5, maxStars: 5, filledColor: '#ff9900' }, '');
|
||||
expect(html).toContain('color:#ff9900');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { cssValue } from '../../utils/escape';
|
||||
|
||||
interface StarRatingProps {
|
||||
rating?: number;
|
||||
@@ -99,9 +100,10 @@ StarRating.craft = {
|
||||
(StarRating as any).toHtml = (props: StarRatingProps, _childrenHtml: string) => {
|
||||
const rating = props.rating ?? 4.5;
|
||||
const maxStars = props.maxStars || 5;
|
||||
const size = props.size || '24px';
|
||||
const filledColor = props.filledColor || '#f59e0b';
|
||||
const emptyColor = props.emptyColor || '#d1d5db';
|
||||
// Sanitized -- raw string-interpolation sinks in the star glyphs below.
|
||||
const size = cssValue(props.size) || '24px';
|
||||
const filledColor = cssValue(props.filledColor) || '#f59e0b';
|
||||
const emptyColor = cssValue(props.emptyColor) || '#d1d5db';
|
||||
const wrapperStyle = cssPropsToString({
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
|
||||
Reference in New Issue
Block a user