fix(builder): neutralize javascript:/breakout URLs in export

Every user-controlled URL emitted by a component's static toHtml (href,
src, action, and CSS url()) now runs through escapeAttr(safeUrl(...))
before hitting the exported HTML string, closing the XSS gaps flagged
in the A2 review (PricingTable buttonHref was fully unescaped, Gallery/
HeroSimple/ImageBlock/VideoBlock/etc. lacked scheme filtering) plus a
few more found via a grep sweep of href=/src=/action=/url( inside
toHtml template strings: MapEmbed's iframe src and the shared
form-relay-wiring fallback form action (a javascript: form action
executes on submit).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-12 12:05:57 -07:00
parent fad1882117
commit 48d0441be3
16 changed files with 200 additions and 35 deletions
+2 -1
View File
@@ -1,6 +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';
interface ButtonLinkProps {
text?: string;
@@ -227,6 +228,6 @@ ButtonLink.craft = {
const escapedText = (props.text || '').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const targetAttr = props.target === '_blank' ? ' target="_blank" rel="noopener noreferrer"' : '';
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 { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { escapeAttr, safeUrl } from '../../utils/escape';
interface IconProps {
icon?: string;
@@ -300,7 +301,7 @@ Icon.craft = {
} = props;
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';
if (hasBg) {
@@ -317,7 +318,7 @@ Icon.craft = {
}
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 });
+3 -3
View File
@@ -2,7 +2,7 @@ import React, { CSSProperties, useCallback, useRef, useState } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { useSiteDesign } from '../../state/SiteDesignContext';
import { escapeHtml, escapeAttr } from '../../utils/escape';
import { escapeHtml, escapeAttr, safeUrl } from '../../utils/escape';
/* ---------- Types ---------- */
@@ -389,7 +389,7 @@ Logo.craft = {
let innerHtml: string;
if (props.type === 'image' && props.imageSrc) {
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 {
const spanStyle = cssPropsToString({
fontWeight: props.fontWeight || '700',
@@ -409,6 +409,6 @@ Logo.craft = {
});
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 { cssPropsToString } from '../../utils/style-helpers';
import { usePages } from '../../state/PageContext';
import { escapeHtml, escapeAttr } from '../../utils/escape';
import { escapeHtml, escapeAttr, safeUrl } from '../../utils/escape';
/* ---------- Types ---------- */
@@ -489,7 +489,7 @@ Menu.craft = {
borderRadius: link.isCta ? '6px' : '0',
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 ');
const hoverCss = `<style>
+5 -5
View File
@@ -3,7 +3,7 @@ import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { usePages } from '../../state/PageContext';
import { useSiteDesign } from '../../state/SiteDesignContext';
import { escapeHtml, escapeAttr } from '../../utils/escape';
import { escapeHtml, escapeAttr, safeUrl } from '../../utils/escape';
/* ---------- Types ---------- */
@@ -848,7 +848,7 @@ Navbar.craft = {
let logoHtml: string;
if (props.logoType === 'image' && props.logoImage) {
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 {
const logoStyle = cssPropsToString({
fontWeight: '700',
@@ -856,7 +856,7 @@ Navbar.craft = {
fontFamily: props.logoFontFamily || 'Inter, sans-serif',
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
@@ -873,7 +873,7 @@ Navbar.craft = {
borderRadius: link.isCta ? '6px' : '0',
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 ');
// Hamburger HTML for mobile
@@ -910,7 +910,7 @@ Navbar.craft = {
borderRadius: link.isCta ? '6px' : '0',
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 ');
return {
+2 -1
View File
@@ -1,6 +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';
interface SocialLink {
platform: string;
@@ -437,7 +438,7 @@ SocialLinks.craft = {
if (hasBg) {
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 ');
return {