From 36c3b2f503daf170eec801830a0427563b202d34 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 12 Jul 2026 15:56:25 -0700 Subject: [PATCH] fix(builder): sanitize CSS-value sinks to prevent 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/alert(1)'); + }); + + 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/); + }); +}); diff --git a/craft/src/components/basic/Menu.tsx b/craft/src/components/basic/Menu.tsx index b354760..e91b4b5 100644 --- a/craft/src/components/basic/Menu.tsx +++ b/craft/src/components/basic/Menu.tsx @@ -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 breakout -> arbitrary alert(1)'); + }); + + test('a backgroundColor value containing alert(2)'); + }); + + test('a ctaColor value containing alert(3)'); + }); + + 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'); + }); +}); diff --git a/craft/src/components/basic/Navbar.tsx b/craft/src/components/basic/Navbar.tsx index 17b510a..6c789c8 100644 --- a/craft/src/components/basic/Navbar.tsx +++ b/craft/src/components/basic/Navbar.tsx @@ -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 + // breakout -> arbitrary '; + const { html } = toHtml({ rating: 3, maxStars: 5, size: malicious }, ''); + expect(html).not.toContain(''); + }); + + test('a normal filled color still renders', () => { + const { html } = toHtml({ rating: 5, maxStars: 5, filledColor: '#ff9900' }, ''); + expect(html).toContain('color:#ff9900'); + }); +}); diff --git a/craft/src/components/basic/StarRating.tsx b/craft/src/components/basic/StarRating.tsx index 6afc986..e5245d5 100644 --- a/craft/src/components/basic/StarRating.tsx +++ b/craft/src/components/basic/StarRating.tsx @@ -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', diff --git a/craft/src/components/forms/ContactForm.tsx b/craft/src/components/forms/ContactForm.tsx index dac3c3e..c86fdae 100644 --- a/craft/src/components/forms/ContactForm.tsx +++ b/craft/src/components/forms/ContactForm.tsx @@ -2,7 +2,7 @@ import React, { CSSProperties } from 'react'; import { useNode, UserComponent } from '@craftjs/core'; import { cssPropsToString } from '../../utils/style-helpers'; import { relayFormWiring } from '../../utils/form-relay-wiring'; -import { escapeHtml, escapeAttr, slugId } from '../../utils/escape'; +import { escapeHtml, escapeAttr, slugId, cssValue } from '../../utils/escape'; interface ContactFormField { type: 'text' | 'email' | 'tel' | 'textarea' | 'select'; @@ -175,9 +175,11 @@ ContactForm.craft = { gap: '20px', ...props.style, }); - const labelColor = props.labelColor || '#374151'; - const inputBg = props.inputBg || '#ffffff'; - const inputBorder = props.inputBorder || '#d1d5db'; + // Sanitized -- raw string-interpolation sinks in inputStyleStr/labelHtml + // below. + const labelColor = cssValue(props.labelColor) || '#374151'; + const inputBg = cssValue(props.inputBg) || '#ffffff'; + const inputBorder = cssValue(props.inputBorder) || '#d1d5db'; const inputStyleStr = `width:100%;padding:10px 14px;font-size:14px;font-family:Inter,sans-serif;border:1px solid ${inputBorder};border-radius:6px;background-color:${inputBg};color:#1f2937;box-sizing:border-box;outline:none`; const fieldsHtml = (props.fields || defaultFields).map((field, i) => { diff --git a/craft/src/components/layout/ColumnLayout.toHtml.test.ts b/craft/src/components/layout/ColumnLayout.toHtml.test.ts index cd4d36d..90c6770 100644 --- a/craft/src/components/layout/ColumnLayout.toHtml.test.ts +++ b/craft/src/components/layout/ColumnLayout.toHtml.test.ts @@ -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 alert(1)'); + }); + + 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' }, '
A
', 'node-normal'); + expect(html).toContain('gap:24px'); + expect(html).toMatch(/calc\(50% - 24px\)/); + }); +}); diff --git a/craft/src/components/layout/ColumnLayout.tsx b/craft/src/components/layout/ColumnLayout.tsx index 1262a4f..d0306ae 100644 --- a/craft/src/components/layout/ColumnLayout.tsx +++ b/craft/src/components/layout/ColumnLayout.tsx @@ -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 + // breakout -> arbitrary ` -- `<`, `>`, `{`, `}` + * let a value close the rule / the `