Files
site-builder/craft/src/components/layout/ColumnLayout.tsx
T
shadowdao 36c3b2f503 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>
2026-07-12 15:56:25 -07:00

154 lines
4.7 KiB
TypeScript

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, cssValue } from '../../utils/escape';
type SplitOption =
| '100'
| '50-50' | '30-70' | '70-30' | '40-60' | '60-40'
| '33-33-33' | '25-50-25'
| '25-25-25-25'
| '20-20-20-20-20'
| '16-16-16-16-16-16'
| 'equal';
interface ColumnLayoutProps {
columns?: number;
split?: SplitOption;
gap?: string;
style?: CSSProperties;
children?: React.ReactNode;
anchorId?: string;
}
const splitToWidths: Record<string, string[]> = {
'100': ['100%'],
'50-50': ['50%', '50%'],
'30-70': ['30%', '70%'],
'70-30': ['70%', '30%'],
'40-60': ['40%', '60%'],
'60-40': ['60%', '40%'],
'33-33-33': ['33.333%', '33.333%', '33.333%'],
'25-50-25': ['25%', '50%', '25%'],
'25-25-25-25': ['25%', '25%', '25%', '25%'],
'20-20-20-20-20': ['20%', '20%', '20%', '20%', '20%'],
'16-16-16-16-16-16': ['16.666%', '16.666%', '16.666%', '16.666%', '16.666%', '16.666%'],
};
function getWidths(split: SplitOption, columns: number): string[] {
// Check predefined splits first
if (split !== 'equal') {
const defined = splitToWidths[split];
if (defined && defined.length === columns) return defined;
}
// Try parsing custom split string (e.g., "35-65" or "25-50-25")
if (split && split !== 'equal' && split.includes('-')) {
const parts = split.split('-').map(Number);
if (parts.length === columns && parts.every(n => !isNaN(n) && n > 0)) {
return parts.map(n => `${n}%`);
}
}
// Fallback: equal widths
const w = `${(100 / columns).toFixed(3)}%`;
return Array.from({ length: columns }, () => w);
}
export const ColumnLayout: UserComponent<ColumnLayoutProps> = ({
columns = 2,
split = '50-50',
gap = '16px',
style = {},
anchorId,
}) => {
const { connectors: { connect, drag } } = useNode();
const widths = getWidths(split, columns);
return (
<div
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
id={anchorId || undefined}
style={{
display: 'flex',
flexWrap: 'wrap',
gap,
width: '100%',
minHeight: '60px',
...style,
}}
>
{widths.map((w, i) => (
<Element
key={`col-${i}`}
id={`col-${i}`}
is={Container}
canvas
custom={{ className: 'craft-column' }}
style={{ flex: `0 0 calc(${w} - ${gap})`, minHeight: '60px', padding: '8px' }}
tag="div"
/>
))}
</div>
);
};
/* ---------- Craft config ---------- */
ColumnLayout.craft = {
displayName: 'Columns',
props: {
columns: 2,
split: '50-50',
gap: '16px',
style: {},
anchorId: '',
},
rules: {
canDrag: () => true,
canMoveIn: () => false,
canMoveOut: () => true,
},
};
/* ---------- HTML export ---------- */
(ColumnLayout as any).toHtml = (props: ColumnLayoutProps, childrenHtml: string, nodeId?: string) => {
const columns = props.columns || 2;
const split = props.split || '50-50';
// 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({
display: 'flex',
flexWrap: 'wrap',
gap,
width: '100%',
...props.style,
});
const idAttr = props.anchorId ? ` id="${escapeAttr(props.anchorId)}"` : '';
// Each column is exported as an independently-serialized child node, so
// toHtml has no direct handle on individual children to rewrite their
// inline flex-basis. Instead, scope an nth-child CSS rule (with
// !important, to win over any stale inline flex baked into a child at
// creation time) to a generated class -- same width mapping (getWidths)
// the editor render uses. Precedent: Menu/Navbar toHtml already emit
// scoped <style> blocks for hover CSS. The class is scoped on the Craft
// node id so two ColumnLayout instances with identical columns/split/gap
// don't collide on the same class and cross-apply each other's widths.
const scope = scopeId(nodeId, `${columns}:${split}:${gap}`, 'cols');
const widthCss = widths
.map((w, i) => `.${scope} > :nth-child(${i + 1}) { flex: 0 0 calc(${w} - ${gap}) !important; }`)
.join('\n ');
return {
html: `<style>\n ${widthCss}\n</style>\n<div class="${scope}"${idAttr}${outerStyle ? ` style="${outerStyle}"` : ''}>${childrenHtml}</div>`,
};
};