Files
site-builder/craft/src/components/sections/_cta-helpers.tsx
T
shadowdaoandClaude Opus 4.8 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

96 lines
3.8 KiB
TypeScript

import { CSSProperties } from 'react';
import { escapeHtml, escapeAttr, safeUrl, cssValue } from '../../utils/escape';
export type CtaVariant = 'primary' | 'outline' | 'ghost';
export interface CtaButton {
text: string;
href: string;
variant?: CtaVariant;
target?: '_blank';
}
export interface CtaStyleDefaults {
primaryBg: string;
primaryText: string;
outlineText: string;
}
/**
* Read the effective list of CTAs for a section, falling back to legacy
* primary/secondary props when ctas[] is absent. New sections write ctas[]
* directly; old sections keep rendering until the user touches the settings.
*/
export function normalizeCtas(props: {
ctas?: CtaButton[];
buttonText?: string;
buttonHref?: string;
secondaryButtonText?: string;
secondaryButtonHref?: string;
}): CtaButton[] {
if (Array.isArray(props.ctas) && props.ctas.length > 0) {
return props.ctas.filter((c) => c && (c.text || c.href));
}
const legacy: CtaButton[] = [];
if (props.buttonText) legacy.push({ text: props.buttonText, href: props.buttonHref || '#', variant: 'primary' });
if (props.secondaryButtonText) legacy.push({ text: props.secondaryButtonText, href: props.secondaryButtonHref || '#', variant: 'outline' });
return legacy;
}
export function ctaInlineStyle(cta: CtaButton, defaults: CtaStyleDefaults): CSSProperties {
const variant = cta.variant || 'primary';
switch (variant) {
case 'outline':
return {
display: 'inline-block', padding: '14px 36px',
backgroundColor: 'transparent', color: defaults.outlineText,
textDecoration: 'none', borderRadius: '8px',
fontWeight: 600, fontSize: '16px',
border: `2px solid ${defaults.outlineText}`,
};
case 'ghost':
return {
display: 'inline-block', padding: '14px 24px',
backgroundColor: 'transparent', color: defaults.outlineText,
textDecoration: 'underline', borderRadius: '8px',
fontWeight: 600, fontSize: '16px',
};
case 'primary':
default:
return {
display: 'inline-block', padding: '14px 36px',
backgroundColor: defaults.primaryBg, color: defaults.primaryText,
textDecoration: 'none', borderRadius: '8px',
fontWeight: 600, fontSize: '16px',
};
}
}
export function ctaCssString(cta: CtaButton, defaults: CtaStyleDefaults): string {
const variant = cta.variant || 'primary';
// Sanitized -- these are raw string-interpolation sinks into style="...".
// Callers pass user-controlled design-token colors (e.g. HeroSimple's
// buttonBgColor/buttonTextColor/textColor) through CtaStyleDefaults, so
// sanitize once here rather than at every call site.
const outlineText = cssValue(defaults.outlineText) || '#000000';
const primaryBg = cssValue(defaults.primaryBg) || '#000000';
const primaryText = cssValue(defaults.primaryText) || '#ffffff';
switch (variant) {
case 'outline':
return `display:inline-block;padding:14px 36px;background-color:transparent;color:${outlineText};text-decoration:none;border-radius:8px;font-weight:600;font-size:16px;border:2px solid ${outlineText}`;
case 'ghost':
return `display:inline-block;padding:14px 24px;background-color:transparent;color:${outlineText};text-decoration:underline;border-radius:8px;font-weight:600;font-size:16px`;
case 'primary':
default:
return `display:inline-block;padding:14px 36px;background-color:${primaryBg};color:${primaryText};text-decoration:none;border-radius:8px;font-weight:600;font-size:16px`;
}
}
export function ctasToHtml(ctas: CtaButton[], defaults: CtaStyleDefaults): string {
return ctas.map((c) => {
const target = c.target === '_blank' ? ' target="_blank" rel="noopener noreferrer"' : '';
return `<a href="${escapeAttr(safeUrl(c.href || '#'))}"${target} style="${ctaCssString(c, defaults)}">${escapeHtml(c.text || '')}</a>`;
}).join('');
}