Site builder: security & data-loss hardening + unified asset picker + audit backlog #3

Merged
jknapp merged 61 commits from builder-hardening-2026-07 into main 2026-07-13 01:13:28 +00:00
16 changed files with 200 additions and 35 deletions
Showing only changes of commit 48d0441be3 - Show all commits
+2 -1
View File
@@ -1,6 +1,7 @@
import React, { CSSProperties } from 'react'; import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core'; import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers'; import { cssPropsToString } from '../../utils/style-helpers';
import { escapeAttr, safeUrl } from '../../utils/escape';
interface ButtonLinkProps { interface ButtonLinkProps {
text?: string; text?: string;
@@ -227,6 +228,6 @@ ButtonLink.craft = {
const escapedText = (props.text || '').replace(/</g, '&lt;').replace(/>/g, '&gt;'); const escapedText = (props.text || '').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const targetAttr = props.target === '_blank' ? ' target="_blank" rel="noopener noreferrer"' : ''; const targetAttr = props.target === '_blank' ? ' target="_blank" rel="noopener noreferrer"' : '';
return { return {
html: `<a href="${props.href || '#'}"${targetAttr}${styleStr ? ` style="${styleStr}"` : ''}>${escapedText}</a>`, html: `<a href="${escapeAttr(safeUrl(props.href || '#'))}"${targetAttr}${styleStr ? ` style="${styleStr}"` : ''}>${escapedText}</a>`,
}; };
}; };
+3 -2
View File
@@ -1,6 +1,7 @@
import React, { CSSProperties } from 'react'; import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core'; import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers'; import { cssPropsToString } from '../../utils/style-helpers';
import { escapeAttr, safeUrl } from '../../utils/escape';
interface IconProps { interface IconProps {
icon?: string; icon?: string;
@@ -300,7 +301,7 @@ Icon.craft = {
} = props; } = props;
const iconStyle = cssPropsToString({ fontSize: size, color, lineHeight: '1' }); const iconStyle = cssPropsToString({ fontSize: size, color, lineHeight: '1' });
let iconHtml = `<i class="fa ${icon}"${iconStyle ? ` style="${iconStyle}"` : ''}></i>`; let iconHtml = `<i class="fa ${escapeAttr(icon)}"${iconStyle ? ` style="${iconStyle}"` : ''}></i>`;
const hasBg = bgShape !== 'none' && bgColor !== 'transparent'; const hasBg = bgShape !== 'none' && bgColor !== 'transparent';
if (hasBg) { if (hasBg) {
@@ -317,7 +318,7 @@ Icon.craft = {
} }
if (link) { if (link) {
iconHtml = `<a href="${link}" style="text-decoration:none;color:inherit">${iconHtml}</a>`; iconHtml = `<a href="${escapeAttr(safeUrl(link))}" style="text-decoration:none;color:inherit">${iconHtml}</a>`;
} }
const wrapperStyle = cssPropsToString({ display: 'inline-block', ...style }); const wrapperStyle = cssPropsToString({ display: 'inline-block', ...style });
+3 -3
View File
@@ -2,7 +2,7 @@ import React, { CSSProperties, useCallback, useRef, useState } from 'react';
import { useNode, UserComponent } from '@craftjs/core'; import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers'; import { cssPropsToString } from '../../utils/style-helpers';
import { useSiteDesign } from '../../state/SiteDesignContext'; import { useSiteDesign } from '../../state/SiteDesignContext';
import { escapeHtml, escapeAttr } from '../../utils/escape'; import { escapeHtml, escapeAttr, safeUrl } from '../../utils/escape';
/* ---------- Types ---------- */ /* ---------- Types ---------- */
@@ -389,7 +389,7 @@ Logo.craft = {
let innerHtml: string; let innerHtml: string;
if (props.type === 'image' && props.imageSrc) { if (props.type === 'image' && props.imageSrc) {
const imgStyle = cssPropsToString({ width: props.imageWidth || '120px', height: 'auto', display: 'block' }); const imgStyle = cssPropsToString({ width: props.imageWidth || '120px', height: 'auto', display: 'block' });
innerHtml = `<img src="${escapeAttr(props.imageSrc)}" alt="${escapeAttr(props.text || 'Logo')}"${imgStyle ? ` style="${imgStyle}"` : ''} />`; innerHtml = `<img src="${escapeAttr(safeUrl(props.imageSrc))}" alt="${escapeAttr(props.text || 'Logo')}"${imgStyle ? ` style="${imgStyle}"` : ''} />`;
} else { } else {
const spanStyle = cssPropsToString({ const spanStyle = cssPropsToString({
fontWeight: props.fontWeight || '700', fontWeight: props.fontWeight || '700',
@@ -409,6 +409,6 @@ Logo.craft = {
}); });
return { return {
html: `<a href="${escapeAttr(href)}"${aStyle ? ` style="${aStyle}"` : ''}>${innerHtml}</a>`, html: `<a href="${escapeAttr(safeUrl(href))}"${aStyle ? ` style="${aStyle}"` : ''}>${innerHtml}</a>`,
}; };
}; };
+2 -2
View File
@@ -2,7 +2,7 @@ import React, { CSSProperties, useState } from 'react';
import { useNode, UserComponent } from '@craftjs/core'; import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers'; import { cssPropsToString } from '../../utils/style-helpers';
import { usePages } from '../../state/PageContext'; import { usePages } from '../../state/PageContext';
import { escapeHtml, escapeAttr } from '../../utils/escape'; import { escapeHtml, escapeAttr, safeUrl } from '../../utils/escape';
/* ---------- Types ---------- */ /* ---------- Types ---------- */
@@ -489,7 +489,7 @@ Menu.craft = {
borderRadius: link.isCta ? '6px' : '0', borderRadius: link.isCta ? '6px' : '0',
transition: 'color 0.15s, background-color 0.15s', transition: 'color 0.15s, background-color 0.15s',
}); });
return `<a href="${escapeAttr(link.href)}" class="${cls}"${target}${linkStyle ? ` style="${linkStyle}"` : ''}>${escapeHtml(link.text)}</a>`; return `<a href="${escapeAttr(safeUrl(link.href || '#'))}" class="${cls}"${target}${linkStyle ? ` style="${linkStyle}"` : ''}>${escapeHtml(link.text)}</a>`;
}).join('\n '); }).join('\n ');
const hoverCss = `<style> const hoverCss = `<style>
+5 -5
View File
@@ -3,7 +3,7 @@ import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers'; import { cssPropsToString } from '../../utils/style-helpers';
import { usePages } from '../../state/PageContext'; import { usePages } from '../../state/PageContext';
import { useSiteDesign } from '../../state/SiteDesignContext'; import { useSiteDesign } from '../../state/SiteDesignContext';
import { escapeHtml, escapeAttr } from '../../utils/escape'; import { escapeHtml, escapeAttr, safeUrl } from '../../utils/escape';
/* ---------- Types ---------- */ /* ---------- Types ---------- */
@@ -848,7 +848,7 @@ Navbar.craft = {
let logoHtml: string; let logoHtml: string;
if (props.logoType === 'image' && props.logoImage) { if (props.logoType === 'image' && props.logoImage) {
const imgStyle = cssPropsToString({ width: props.logoWidth || '120px', height: 'auto', display: 'block' }); const imgStyle = cssPropsToString({ width: props.logoWidth || '120px', height: 'auto', display: 'block' });
logoHtml = `<a href="${escapeAttr(logoUrl)}" style="text-decoration:none;display:flex;align-items:center;flex-shrink:0"><img src="${escapeAttr(props.logoImage)}" alt="${escapeAttr(props.logoText || 'Logo')}"${imgStyle ? ` style="${imgStyle}"` : ''} /></a>`; logoHtml = `<a href="${escapeAttr(safeUrl(logoUrl))}" style="text-decoration:none;display:flex;align-items:center;flex-shrink:0"><img src="${escapeAttr(safeUrl(props.logoImage))}" alt="${escapeAttr(props.logoText || 'Logo')}"${imgStyle ? ` style="${imgStyle}"` : ''} /></a>`;
} else { } else {
const logoStyle = cssPropsToString({ const logoStyle = cssPropsToString({
fontWeight: '700', fontWeight: '700',
@@ -856,7 +856,7 @@ Navbar.craft = {
fontFamily: props.logoFontFamily || 'Inter, sans-serif', fontFamily: props.logoFontFamily || 'Inter, sans-serif',
color: props.logoColor || textCol, color: props.logoColor || textCol,
}); });
logoHtml = `<a href="${escapeAttr(logoUrl)}" style="text-decoration:none;display:flex;align-items:center;flex-shrink:0"><span${logoStyle ? ` style="${logoStyle}"` : ''}>${escapeHtml(props.logoText || 'MySite')}</span></a>`; logoHtml = `<a href="${escapeAttr(safeUrl(logoUrl))}" style="text-decoration:none;display:flex;align-items:center;flex-shrink:0"><span${logoStyle ? ` style="${logoStyle}"` : ''}>${escapeHtml(props.logoText || 'MySite')}</span></a>`;
} }
// Links HTML // Links HTML
@@ -873,7 +873,7 @@ Navbar.craft = {
borderRadius: link.isCta ? '6px' : '0', borderRadius: link.isCta ? '6px' : '0',
transition: 'color 0.15s, background-color 0.15s', transition: 'color 0.15s, background-color 0.15s',
}); });
return `<a href="${escapeAttr(link.href)}"${target}${linkStyle ? ` style="${linkStyle}"` : ''}>${escapeHtml(link.text)}</a>`; return `<a href="${escapeAttr(safeUrl(link.href || "#"))}"${target}${linkStyle ? ` style="${linkStyle}"` : ''}>${escapeHtml(link.text)}</a>`;
}).join('\n '); }).join('\n ');
// Hamburger HTML for mobile // Hamburger HTML for mobile
@@ -910,7 +910,7 @@ Navbar.craft = {
borderRadius: link.isCta ? '6px' : '0', borderRadius: link.isCta ? '6px' : '0',
transition: 'color 0.15s, background-color 0.15s', transition: 'color 0.15s, background-color 0.15s',
}); });
return `<a href="${escapeAttr(link.href)}" class="${cls}"${target}${linkStyle ? ` style="${linkStyle}"` : ''}>${escapeHtml(link.text)}</a>`; return `<a href="${escapeAttr(safeUrl(link.href || "#"))}" class="${cls}"${target}${linkStyle ? ` style="${linkStyle}"` : ''}>${escapeHtml(link.text)}</a>`;
}).join('\n '); }).join('\n ');
return { return {
+2 -1
View File
@@ -1,6 +1,7 @@
import React, { CSSProperties } from 'react'; import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core'; import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers'; import { cssPropsToString } from '../../utils/style-helpers';
import { escapeAttr, safeUrl } from '../../utils/escape';
interface SocialLink { interface SocialLink {
platform: string; platform: string;
@@ -437,7 +438,7 @@ SocialLinks.craft = {
if (hasBg) { if (hasBg) {
aStyle += `;${getShapeStr()}`; aStyle += `;${getShapeStr()}`;
} }
return `<a href="${link.url || '#'}" target="_blank" rel="noopener noreferrer" title="${title}" style="${aStyle}"><i class="fa ${iconClass}" style="font-size:${iconSize}"></i></a>`; return `<a href="${escapeAttr(safeUrl(link.url || '#'))}" target="_blank" rel="noopener noreferrer" title="${escapeAttr(title)}" style="${aStyle}"><i class="fa ${escapeAttr(iconClass)}" style="font-size:${iconSize}"></i></a>`;
}).join('\n '); }).join('\n ');
return { return {
+3 -2
View File
@@ -1,6 +1,7 @@
import React, { CSSProperties, useCallback, useRef, useState } from 'react'; import React, { CSSProperties, useCallback, useRef, useState } from 'react';
import { useNode, UserComponent } from '@craftjs/core'; import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers'; import { cssPropsToString } from '../../utils/style-helpers';
import { escapeAttr, safeUrl } from '../../utils/escape';
const PLACEHOLDER_SRC = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='400' height='300'%3E%3Cdefs%3E%3ClinearGradient id='bg' x1='0' y1='0' x2='0' y2='1'%3E%3Cstop offset='0%25' stop-color='%23f1f5f9'/%3E%3Cstop offset='100%25' stop-color='%23e2e8f0'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect fill='url(%23bg)' width='400' height='300' rx='12'/%3E%3Crect x='2' y='2' width='396' height='296' rx='10' fill='none' stroke='%23cbd5e1' stroke-width='2' stroke-dasharray='8 4'/%3E%3Cg transform='translate(200,110)'%3E%3Crect x='-28' y='-28' width='56' height='56' rx='12' fill='%23cbd5e1' opacity='0.5'/%3E%3Cpath d='M-12 8 L-4 -2 L2 4 L8 -6 L16 8Z' fill='%2394a3b8'/%3E%3Ccircle cx='-6' cy='-10' r='5' fill='%2394a3b8'/%3E%3C/g%3E%3Ctext x='200' y='160' text-anchor='middle' fill='%2364748b' font-family='Inter,sans-serif' font-size='15' font-weight='500'%3EDrop image here%3C/text%3E%3Ctext x='200' y='182' text-anchor='middle' fill='%2394a3b8' font-family='Inter,sans-serif' font-size='12'%3Eor click to upload%3C/text%3E%3C/svg%3E"; const PLACEHOLDER_SRC = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='400' height='300'%3E%3Cdefs%3E%3ClinearGradient id='bg' x1='0' y1='0' x2='0' y2='1'%3E%3Cstop offset='0%25' stop-color='%23f1f5f9'/%3E%3Cstop offset='100%25' stop-color='%23e2e8f0'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect fill='url(%23bg)' width='400' height='300' rx='12'/%3E%3Crect x='2' y='2' width='396' height='296' rx='10' fill='none' stroke='%23cbd5e1' stroke-width='2' stroke-dasharray='8 4'/%3E%3Cg transform='translate(200,110)'%3E%3Crect x='-28' y='-28' width='56' height='56' rx='12' fill='%23cbd5e1' opacity='0.5'/%3E%3Cpath d='M-12 8 L-4 -2 L2 4 L8 -6 L16 8Z' fill='%2394a3b8'/%3E%3Ccircle cx='-6' cy='-10' r='5' fill='%2394a3b8'/%3E%3C/g%3E%3Ctext x='200' y='160' text-anchor='middle' fill='%2364748b' font-family='Inter,sans-serif' font-size='15' font-weight='500'%3EDrop image here%3C/text%3E%3Ctext x='200' y='182' text-anchor='middle' fill='%2394a3b8' font-family='Inter,sans-serif' font-size='12'%3Eor click to upload%3C/text%3E%3C/svg%3E";
@@ -475,6 +476,6 @@ ImageBlock.craft = {
return { html: '' }; return { html: '' };
} }
const s = cssPropsToString({ display: 'block', maxWidth: '100%', ...props.style }); const s = cssPropsToString({ display: 'block', maxWidth: '100%', ...props.style });
const alt = props.alt ? ` alt="${props.alt.replace(/"/g, '&quot;')}"` : ' alt=""'; const alt = props.alt ? ` alt="${escapeAttr(props.alt)}"` : ' alt=""';
return { html: `<img src="${src}"${alt}${s ? ` style="${s}"` : ''} />` }; return { html: `<img src="${escapeAttr(safeUrl(src))}"${alt}${s ? ` style="${s}"` : ''} />` };
}; };
+2 -1
View File
@@ -1,6 +1,7 @@
import React, { CSSProperties } from 'react'; import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core'; import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers'; import { cssPropsToString } from '../../utils/style-helpers';
import { escapeAttr, safeUrl } from '../../utils/escape';
interface MapEmbedProps { interface MapEmbedProps {
address?: string; address?: string;
@@ -168,6 +169,6 @@ MapEmbed.craft = {
const src = buildMapUrl(address, zoom); const src = buildMapUrl(address, zoom);
return { return {
html: `<div${wrapperStyle ? ` style="${wrapperStyle}"` : ''}><iframe src="${src}" loading="lazy" referrerpolicy="no-referrer-when-downgrade" allowfullscreen${iframeStyle ? ` style="${iframeStyle}"` : ''}></iframe></div>`, html: `<div${wrapperStyle ? ` style="${wrapperStyle}"` : ''}><iframe src="${escapeAttr(safeUrl(src))}" loading="lazy" referrerpolicy="no-referrer-when-downgrade" allowfullscreen${iframeStyle ? ` style="${iframeStyle}"` : ''}></iframe></div>`,
}; };
}; };
+5 -4
View File
@@ -2,6 +2,7 @@ import React, { CSSProperties, useCallback, useRef, useState } from 'react';
import { useNode, Element, UserComponent } from '@craftjs/core'; import { useNode, Element, UserComponent } from '@craftjs/core';
import { Container } from '../layout/Container'; import { Container } from '../layout/Container';
import { cssPropsToString } from '../../utils/style-helpers'; import { cssPropsToString } from '../../utils/style-helpers';
import { escapeAttr, safeUrl } from '../../utils/escape';
/* ---------- Types ---------- */ /* ---------- Types ---------- */
@@ -722,7 +723,7 @@ VideoBlock.craft = {
zIndex: '0', zIndex: '0',
pointerEvents: 'none', pointerEvents: 'none',
}); });
videoHtml = `<video src="${embedUrl}" autoplay muted loop playsinline${vidStyle ? ` style="${vidStyle}"` : ''}></video>`; videoHtml = `<video src="${escapeAttr(safeUrl(embedUrl))}" autoplay muted loop playsinline${vidStyle ? ` style="${vidStyle}"` : ''}></video>`;
} else if ((type === 'youtube' || type === 'vimeo') && embedUrl) { } else if ((type === 'youtube' || type === 'vimeo') && embedUrl) {
const iframeSrc = buildEmbedParams(embedUrl, { autoplay: true, muted: true, loop: true, controls: false }); const iframeSrc = buildEmbedParams(embedUrl, { autoplay: true, muted: true, loop: true, controls: false });
const ifrStyle = cssPropsToString({ const ifrStyle = cssPropsToString({
@@ -738,7 +739,7 @@ VideoBlock.craft = {
zIndex: '0', zIndex: '0',
pointerEvents: 'none', pointerEvents: 'none',
}); });
videoHtml = `<iframe src="${iframeSrc}" allow="autoplay; encrypted-media" allowfullscreen${ifrStyle ? ` style="${ifrStyle}"` : ''}></iframe>`; videoHtml = `<iframe src="${escapeAttr(safeUrl(iframeSrc))}" allow="autoplay; encrypted-media" allowfullscreen${ifrStyle ? ` style="${ifrStyle}"` : ''}></iframe>`;
} }
return { return {
@@ -771,7 +772,7 @@ VideoBlock.craft = {
border: 'none', border: 'none',
}); });
return { return {
html: `<div${wrapperStyle ? ` style="${wrapperStyle}"` : ''}><div${containerStyle ? ` style="${containerStyle}"` : ''}><iframe src="${iframeSrc}" allow="autoplay; encrypted-media; picture-in-picture" allowfullscreen${iframeStyle ? ` style="${iframeStyle}"` : ''}></iframe></div></div>`, html: `<div${wrapperStyle ? ` style="${wrapperStyle}"` : ''}><div${containerStyle ? ` style="${containerStyle}"` : ''}><iframe src="${escapeAttr(safeUrl(iframeSrc))}" allow="autoplay; encrypted-media; picture-in-picture" allowfullscreen${iframeStyle ? ` style="${iframeStyle}"` : ''}></iframe></div></div>`,
}; };
} }
@@ -789,6 +790,6 @@ VideoBlock.craft = {
}); });
return { return {
html: `<div${wrapperStyle ? ` style="${wrapperStyle}"` : ''}><video src="${embedUrl}" ${vidAttrs.join(' ')}${vidStyle ? ` style="${vidStyle}"` : ''}></video></div>`, html: `<div${wrapperStyle ? ` style="${wrapperStyle}"` : ''}><video src="${escapeAttr(safeUrl(embedUrl))}" ${vidAttrs.join(' ')}${vidStyle ? ` style="${vidStyle}"` : ''}></video></div>`,
}; };
}; };
@@ -1,7 +1,7 @@
import React, { CSSProperties, useState, useEffect, useRef, useCallback } from 'react'; import React, { CSSProperties, useState, useEffect, useRef, useCallback } from 'react';
import { useNode, UserComponent } from '@craftjs/core'; import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers'; import { cssPropsToString } from '../../utils/style-helpers';
import { escapeHtml, escapeAttr } from '../../utils/escape'; import { escapeHtml, escapeAttr, safeUrl } from '../../utils/escape';
interface Slide { interface Slide {
type: 'image' | 'content'; type: 'image' | 'content';
@@ -468,7 +468,7 @@ ContentSlider.craft = {
const slidesHtml = items.map((slide, i) => { const slidesHtml = items.map((slide, i) => {
const hasBgImage = slide.imageSrc; const hasBgImage = slide.imageSrc;
const bgStyle = hasBgImage const bgStyle = hasBgImage
? `background-image:url(${escapeAttr(slide.imageSrc!)});background-size:cover;background-position:center` ? `background-image:url('${escapeAttr(safeUrl(slide.imageSrc!))}');background-size:cover;background-position:center`
: slide.bgColor?.startsWith('linear-gradient') : slide.bgColor?.startsWith('linear-gradient')
? `background-image:${slide.bgColor}` ? `background-image:${slide.bgColor}`
: `background-color:${slide.bgColor || '#3b82f6'}`; : `background-color:${slide.bgColor || '#3b82f6'}`;
@@ -481,7 +481,7 @@ ContentSlider.craft = {
contentParts.push(`<p style="font-size:18px;color:rgba(255,255,255,0.9);margin-bottom:20px;font-family:Inter,sans-serif;text-shadow:0 1px 4px rgba(0,0,0,0.3)">${escapeHtml(slide.text)}</p>`); contentParts.push(`<p style="font-size:18px;color:rgba(255,255,255,0.9);margin-bottom:20px;font-family:Inter,sans-serif;text-shadow:0 1px 4px rgba(0,0,0,0.3)">${escapeHtml(slide.text)}</p>`);
} }
if (slide.buttonText) { if (slide.buttonText) {
contentParts.push(`<a href="${escapeAttr(slide.buttonHref || '#')}" style="display:inline-block;padding:12px 28px;background:#ffffff;color:#18181b;text-decoration:none;border-radius:8px;font-weight:600;font-size:15px;font-family:Inter,sans-serif">${escapeHtml(slide.buttonText)}</a>`); contentParts.push(`<a href="${escapeAttr(safeUrl(slide.buttonHref || '#'))}" style="display:inline-block;padding:12px 28px;background:#ffffff;color:#18181b;text-decoration:none;border-radius:8px;font-weight:600;font-size:15px;font-family:Inter,sans-serif">${escapeHtml(slide.buttonText)}</a>`);
} }
const innerHtml = contentParts.length > 0 const innerHtml = contentParts.length > 0
@@ -2,7 +2,7 @@ import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core'; import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers'; import { cssPropsToString } from '../../utils/style-helpers';
import { AnchorIdField } from '../../ui/AnchorIdField'; import { AnchorIdField } from '../../ui/AnchorIdField';
import { escapeHtml, escapeAttr } from '../../utils/escape'; import { escapeHtml, escapeAttr, safeUrl } from '../../utils/escape';
interface FeatureItem { interface FeatureItem {
title: string; title: string;
@@ -286,10 +286,10 @@ FeaturesGrid.craft = {
const idAttr = props.anchorId ? ` id="${escapeAttr(props.anchorId)}"` : ''; const idAttr = props.anchorId ? ` id="${escapeAttr(props.anchorId)}"` : '';
const cards = (props.features || defaultFeatures).map((feat) => { const cards = (props.features || defaultFeatures).map((feat) => {
const media = feat.image const media = feat.image
? `<img src="${escapeAttr(feat.image)}" alt="${escapeAttr(feat.imageAlt || feat.title || '')}" style="max-width:100%;height:auto;margin-bottom:16px;border-radius:8px">` ? `<img src="${escapeAttr(safeUrl(feat.image))}" alt="${escapeAttr(feat.imageAlt || feat.title || '')}" style="max-width:100%;height:auto;margin-bottom:16px;border-radius:8px">`
: `<div style="font-size:36px;margin-bottom:16px">${escapeHtml(feat.icon)}</div>`; : `<div style="font-size:36px;margin-bottom:16px">${escapeHtml(feat.icon)}</div>`;
const button = feat.buttonText const button = feat.buttonText
? `\n <a href="${escapeAttr(feat.buttonUrl || '#')}" style="display:inline-block;margin-top:16px;padding:10px 24px;background:#3b82f6;color:#fff;border-radius:8px;text-decoration:none;font-size:14px;font-weight:600">${escapeHtml(feat.buttonText)}</a>` ? `\n <a href="${escapeAttr(safeUrl(feat.buttonUrl || '#'))}" style="display:inline-block;margin-top:16px;padding:10px 24px;background:#3b82f6;color:#fff;border-radius:8px;text-decoration:none;font-size:14px;font-weight:600">${escapeHtml(feat.buttonText)}</a>`
: ''; : '';
return `<div style="text-align:center;padding:32px 24px;border-radius:12px;background-color:#f8fafc;border:1px solid #e2e8f0"> return `<div style="text-align:center;padding:32px 24px;border-radius:12px;background-color:#f8fafc;border:1px solid #e2e8f0">
${media} ${media}
+2 -2
View File
@@ -3,7 +3,7 @@ import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers'; import { cssPropsToString } from '../../utils/style-helpers';
import { CtaButton, CtasEditor, normalizeCtas, ctaInlineStyle, ctasToHtml } from './_cta-helpers'; import { CtaButton, CtasEditor, normalizeCtas, ctaInlineStyle, ctasToHtml } from './_cta-helpers';
import { AnchorIdField } from '../../ui/AnchorIdField'; import { AnchorIdField } from '../../ui/AnchorIdField';
import { escapeHtml, escapeAttr } from '../../utils/escape'; import { escapeHtml, escapeAttr, safeUrl } from '../../utils/escape';
interface HeroProps { interface HeroProps {
heading?: string; heading?: string;
@@ -438,7 +438,7 @@ HeroSimple.craft = {
let videoHtml = ''; let videoHtml = '';
if (props.bgType === 'video' && props.bgVideo) { if (props.bgType === 'video' && props.bgVideo) {
videoHtml = `<video src="${props.bgVideo}" autoplay muted loop playsinline style="position:absolute;top:0;left:0;width:100%;height:100%;object-fit:cover;z-index:0"></video>`; videoHtml = `<video src="${escapeAttr(safeUrl(props.bgVideo))}" autoplay muted loop playsinline style="position:absolute;top:0;left:0;width:100%;height:100%;object-fit:cover;z-index:0"></video>`;
} }
const textAlign = props.textAlign || 'center'; const textAlign = props.textAlign || 'center';
@@ -2,7 +2,7 @@ import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core'; import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers'; import { cssPropsToString } from '../../utils/style-helpers';
import { AnchorIdField } from '../../ui/AnchorIdField'; import { AnchorIdField } from '../../ui/AnchorIdField';
import { escapeHtml, escapeAttr } from '../../utils/escape'; import { escapeHtml, escapeAttr, safeUrl } from '../../utils/escape';
interface PricingPlan { interface PricingPlan {
name: string; name: string;
@@ -444,7 +444,7 @@ PricingTable.craft = {
<ul style="list-style:none;padding:0;margin:0 0 32px 0;width:100%;display:flex;flex-direction:column;gap:12px"> <ul style="list-style:none;padding:0;margin:0 0 32px 0;width:100%;display:flex;flex-direction:column;gap:12px">
${featuresHtml} ${featuresHtml}
</ul> </ul>
<a href="${plan.buttonHref || '#'}" style="margin-top:auto;display:inline-block;padding:14px 32px;background-color:${btnBg};color:${btnColor};text-decoration:none;border-radius:8px;font-weight:600;font-size:14px;width:100%;text-align:center">${escapeHtml(plan.buttonText)}</a> <a href="${escapeAttr(safeUrl(plan.buttonHref || '#'))}" style="margin-top:auto;display:inline-block;padding:14px 32px;background-color:${btnBg};color:${btnColor};text-decoration:none;border-radius:8px;font-weight:600;font-size:14px;width:100%;text-align:center">${escapeHtml(plan.buttonText)}</a>
</div>`; </div>`;
}).join('\n '); }).join('\n ');
@@ -1,5 +1,5 @@
import React, { CSSProperties } from 'react'; import React, { CSSProperties } from 'react';
import { escapeHtml, escapeAttr } from '../../utils/escape'; import { escapeHtml, escapeAttr, safeUrl } from '../../utils/escape';
export type CtaVariant = 'primary' | 'outline' | 'ghost'; export type CtaVariant = 'primary' | 'outline' | 'ghost';
@@ -82,7 +82,7 @@ export function ctaCssString(cta: CtaButton, defaults: CtaStyleDefaults): string
export function ctasToHtml(ctas: CtaButton[], defaults: CtaStyleDefaults): string { export function ctasToHtml(ctas: CtaButton[], defaults: CtaStyleDefaults): string {
return ctas.map((c) => { return ctas.map((c) => {
const target = c.target === '_blank' ? ' target="_blank" rel="noopener noreferrer"' : ''; const target = c.target === '_blank' ? ' target="_blank" rel="noopener noreferrer"' : '';
return `<a href="${escapeAttr(c.href || '#')}"${target} style="${ctaCssString(c, defaults)}">${escapeHtml(c.text || '')}</a>`; return `<a href="${escapeAttr(safeUrl(c.href || '#'))}"${target} style="${ctaCssString(c, defaults)}">${escapeHtml(c.text || '')}</a>`;
}).join(''); }).join('');
} }
@@ -0,0 +1,159 @@
import { describe, test, expect } from 'vitest';
import { ButtonLink } from './basic/ButtonLink';
import { Icon } from './basic/Icon';
import { SocialLinks } from './basic/SocialLinks';
import { Logo } from './basic/Logo';
import { Menu } from './basic/Menu';
import { Navbar } from './basic/Navbar';
import { ContentSlider } from './sections/ContentSlider';
import { FeaturesGrid } from './sections/FeaturesGrid';
import { PricingTable } from './sections/PricingTable';
import { ImageBlock } from './media/ImageBlock';
import { VideoBlock } from './media/VideoBlock';
import { Gallery } from './sections/Gallery';
import { BackgroundSection } from './layout/BackgroundSection';
import { HeroSimple } from './sections/HeroSimple';
import { CallToAction } from './sections/CallToAction';
import { MapEmbed } from './media/MapEmbed';
import { FormContainer } from './forms/FormContainer';
const XSS = 'javascript:alert(1)';
const QUOTE_BREAKOUT = 'x" onerror="alert(1)"';
function toHtmlOf(Component: any) {
return Component.toHtml as (props: any, childrenHtml: string) => { html: string };
}
describe('A3: exported URLs are wrapped in safeUrl + escapeAttr', () => {
test('ButtonLink href', () => {
const html = toHtmlOf(ButtonLink)({ href: XSS, text: 'Go' }, '').html;
expect(html).not.toContain('javascript:');
const html2 = toHtmlOf(ButtonLink)({ href: QUOTE_BREAKOUT, text: 'Go' }, '').html;
expect(html2).not.toContain('onerror="alert(1)"');
});
test('Icon href (link), class is escaped but NOT safeUrl-filtered', () => {
const html = toHtmlOf(Icon)({ link: XSS, icon: 'fa-star' }, '').html;
expect(html).not.toContain('javascript:');
const html2 = toHtmlOf(Icon)({ link: QUOTE_BREAKOUT, icon: 'fa-star' }, '').html;
expect(html2).not.toContain('onerror="alert(1)"');
// class attribute is a CSS class, not a URL -- still escaped for attr safety
const html3 = toHtmlOf(Icon)({ icon: 'fa-star" onerror="alert(1)' }, '').html;
expect(html3).not.toContain('onerror="alert(1)"');
});
test('SocialLinks href', () => {
const html = toHtmlOf(SocialLinks)({ links: [{ platform: 'facebook', url: XSS }] }, '').html;
expect(html).not.toContain('javascript:');
const html2 = toHtmlOf(SocialLinks)({ links: [{ platform: 'facebook', url: QUOTE_BREAKOUT }] }, '').html;
expect(html2).not.toContain('onerror="alert(1)"');
});
test('Logo href + image src', () => {
const html = toHtmlOf(Logo)({ href: XSS, type: 'text', text: 'Site' }, '').html;
expect(html).not.toContain('javascript:');
const html2 = toHtmlOf(Logo)({ type: 'image', imageSrc: XSS }, '').html;
expect(html2).not.toContain('javascript:');
const html3 = toHtmlOf(Logo)({ type: 'image', imageSrc: QUOTE_BREAKOUT }, '').html;
expect(html3).not.toContain('onerror="alert(1)"');
});
test('Menu link href', () => {
const html = toHtmlOf(Menu)({ links: [{ text: 'x', href: XSS }] }, '').html;
expect(html).not.toContain('javascript:');
const html2 = toHtmlOf(Menu)({ links: [{ text: 'x', href: QUOTE_BREAKOUT }] }, '').html;
expect(html2).not.toContain('onerror="alert(1)"');
});
test('Navbar logo href, logo image src, link hrefs', () => {
const html = toHtmlOf(Navbar)({ logoUrl: XSS, logoType: 'text', logoText: 'Site', links: [{ text: 'x', href: XSS }] }, '').html;
expect(html).not.toContain('javascript:');
const html2 = toHtmlOf(Navbar)({ logoType: 'image', logoImage: XSS }, '').html;
expect(html2).not.toContain('javascript:');
const html3 = toHtmlOf(Navbar)({ logoUrl: QUOTE_BREAKOUT, links: [{ text: 'x', href: QUOTE_BREAKOUT }] }, '').html;
expect(html3).not.toContain('onerror="alert(1)"');
});
test('ContentSlider button href + slide image src (background url)', () => {
const html = toHtmlOf(ContentSlider)({ slides: [{ buttonText: 'Go', buttonHref: XSS, imageSrc: '' }] }, '').html;
expect(html).not.toContain('javascript:');
const html2 = toHtmlOf(ContentSlider)({ slides: [{ imageSrc: XSS }] }, '').html;
expect(html2).not.toContain('javascript:');
const html3 = toHtmlOf(ContentSlider)({ slides: [{ buttonText: 'Go', buttonHref: QUOTE_BREAKOUT }] }, '').html;
expect(html3).not.toContain('onerror="alert(1)"');
});
test('FeaturesGrid image src + button url', () => {
const html = toHtmlOf(FeaturesGrid)({ features: [{ title: 't', description: 'd', image: XSS }] }, '').html;
expect(html).not.toContain('javascript:');
const html2 = toHtmlOf(FeaturesGrid)({ features: [{ title: 't', description: 'd', buttonText: 'Go', buttonUrl: XSS }] }, '').html;
expect(html2).not.toContain('javascript:');
});
test('PricingTable button href', () => {
const html = toHtmlOf(PricingTable)({ plans: [{ name: 'p', price: '$1', period: '/mo', features: [], buttonText: 'Buy', buttonHref: XSS }] }, '').html;
expect(html).not.toContain('javascript:');
const html2 = toHtmlOf(PricingTable)({ plans: [{ name: 'p', price: '$1', period: '/mo', features: [], buttonText: 'Buy', buttonHref: QUOTE_BREAKOUT }] }, '').html;
expect(html2).not.toContain('onerror="alert(1)"');
});
test('ImageBlock img src', () => {
const html = toHtmlOf(ImageBlock)({ src: XSS }, '').html;
expect(html).not.toContain('javascript:');
const html2 = toHtmlOf(ImageBlock)({ src: QUOTE_BREAKOUT }, '').html;
expect(html2).not.toContain('onerror="alert(1)"');
});
test('VideoBlock direct-file src is safeUrl-filtered on final emitted src', () => {
// matches the .mp4 extension sniff in detectVideoType but carries a javascript: scheme
const html = toHtmlOf(VideoBlock)({ videoUrl: 'javascript:alert(1)//x.mp4' }, '').html;
expect(html).not.toContain('javascript:');
const html2 = toHtmlOf(VideoBlock)({ videoUrl: '"><script>alert(1)</script>.mp4' }, '').html;
expect(html2).not.toContain('<script>alert(1)</script>');
});
test('VideoBlock youtube embed still works after safeUrl pass', () => {
const html = toHtmlOf(VideoBlock)({ videoUrl: 'https://www.youtube.com/watch?v=abc123' }, '').html;
expect(html).toContain('https://www.youtube.com/embed/abc123');
});
test('Gallery img src', () => {
const html = toHtmlOf(Gallery)({ images: [{ src: XSS, alt: 'a' }] }, '').html;
expect(html).not.toContain('javascript:');
const html2 = toHtmlOf(Gallery)({ images: [{ src: QUOTE_BREAKOUT, alt: 'a' }] }, '').html;
expect(html2).not.toContain('onerror="alert(1)"');
});
test('BackgroundSection bg image url()', () => {
const html = toHtmlOf(BackgroundSection)({ bgImage: XSS }, '').html;
expect(html).not.toContain('javascript:');
});
test('HeroSimple bg image url() and bg video src', () => {
const html = toHtmlOf(HeroSimple)({ bgType: 'image', bgImage: XSS }, '').html;
expect(html).not.toContain('javascript:');
const html2 = toHtmlOf(HeroSimple)({ bgType: 'video', bgVideo: XSS }, '').html;
expect(html2).not.toContain('javascript:');
const html3 = toHtmlOf(HeroSimple)({ bgType: 'video', bgVideo: QUOTE_BREAKOUT }, '').html;
expect(html3).not.toContain('onerror="alert(1)"');
});
test('CallToAction bg image url()', () => {
const html = toHtmlOf(CallToAction)({ bgType: 'image', bgValue: XSS }, '').html;
expect(html).not.toContain('javascript:');
});
test('MapEmbed iframe src (found beyond the brief-listed sites via grep sweep)', () => {
const html = toHtmlOf(MapEmbed)({ address: 'New York, NY', zoom: 14 }, '').html;
expect(html).toContain('maps.google.com');
expect(html).not.toContain('javascript:');
});
test('FormContainer legacy form action is safeUrl-filtered (found via grep sweep)', () => {
const html = toHtmlOf(FormContainer)({ action: XSS, method: 'GET' }, '').html;
expect(html).not.toContain('javascript:');
// non-relay legacy path (no recipientEmail) still works normally
const html2 = toHtmlOf(FormContainer)({ action: '/legacy', method: 'POST' }, '').html;
expect(html2).toContain('action="/legacy"');
});
});
+2 -2
View File
@@ -9,7 +9,7 @@
* HTML — see PR #47 review). * HTML — see PR #47 review).
*/ */
import { escapeAttr } from './escape'; import { escapeAttr, safeUrl } from './escape';
export interface RelayWiring { export interface RelayWiring {
/** true when a recipient is set (relay path); false = legacy formAction fallback */ /** true when a recipient is set (relay path); false = legacy formAction fallback */
@@ -33,7 +33,7 @@ export function relayFormWiring(
fallbackAction: string | undefined, fallbackAction: string | undefined,
): RelayWiring { ): RelayWiring {
if (!recipientEmail) { if (!recipientEmail) {
return { useRelay: false, marker: '', actionAttr: escapeAttr(fallbackAction || '#'), honeypot: '' }; return { useRelay: false, marker: '', actionAttr: escapeAttr(safeUrl(fallbackAction || '#')), honeypot: '' };
} }
const fid = 'F' + Math.random().toString(36).slice(2, 8); const fid = 'F' + Math.random().toString(36).slice(2, 8);
return { return {