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 {
+3 -2
View File
@@ -1,6 +1,7 @@
import React, { CSSProperties, useCallback, useRef, useState } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
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";
@@ -475,6 +476,6 @@ ImageBlock.craft = {
return { html: '' };
}
const s = cssPropsToString({ display: 'block', maxWidth: '100%', ...props.style });
const alt = props.alt ? ` alt="${props.alt.replace(/"/g, '&quot;')}"` : ' alt=""';
return { html: `<img src="${src}"${alt}${s ? ` style="${s}"` : ''} />` };
const alt = props.alt ? ` alt="${escapeAttr(props.alt)}"` : ' alt=""';
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 { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { escapeAttr, safeUrl } from '../../utils/escape';
interface MapEmbedProps {
address?: string;
@@ -168,6 +169,6 @@ MapEmbed.craft = {
const src = buildMapUrl(address, zoom);
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 { Container } from '../layout/Container';
import { cssPropsToString } from '../../utils/style-helpers';
import { escapeAttr, safeUrl } from '../../utils/escape';
/* ---------- Types ---------- */
@@ -722,7 +723,7 @@ VideoBlock.craft = {
zIndex: '0',
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) {
const iframeSrc = buildEmbedParams(embedUrl, { autoplay: true, muted: true, loop: true, controls: false });
const ifrStyle = cssPropsToString({
@@ -738,7 +739,7 @@ VideoBlock.craft = {
zIndex: '0',
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 {
@@ -771,7 +772,7 @@ VideoBlock.craft = {
border: 'none',
});
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 {
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 { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { escapeHtml, escapeAttr } from '../../utils/escape';
import { escapeHtml, escapeAttr, safeUrl } from '../../utils/escape';
interface Slide {
type: 'image' | 'content';
@@ -468,7 +468,7 @@ ContentSlider.craft = {
const slidesHtml = items.map((slide, i) => {
const hasBgImage = slide.imageSrc;
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')
? `background-image:${slide.bgColor}`
: `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>`);
}
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
@@ -2,7 +2,7 @@ import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { AnchorIdField } from '../../ui/AnchorIdField';
import { escapeHtml, escapeAttr } from '../../utils/escape';
import { escapeHtml, escapeAttr, safeUrl } from '../../utils/escape';
interface FeatureItem {
title: string;
@@ -286,10 +286,10 @@ FeaturesGrid.craft = {
const idAttr = props.anchorId ? ` id="${escapeAttr(props.anchorId)}"` : '';
const cards = (props.features || defaultFeatures).map((feat) => {
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>`;
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">
${media}
+2 -2
View File
@@ -3,7 +3,7 @@ import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { CtaButton, CtasEditor, normalizeCtas, ctaInlineStyle, ctasToHtml } from './_cta-helpers';
import { AnchorIdField } from '../../ui/AnchorIdField';
import { escapeHtml, escapeAttr } from '../../utils/escape';
import { escapeHtml, escapeAttr, safeUrl } from '../../utils/escape';
interface HeroProps {
heading?: string;
@@ -438,7 +438,7 @@ HeroSimple.craft = {
let videoHtml = '';
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';
@@ -2,7 +2,7 @@ import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { AnchorIdField } from '../../ui/AnchorIdField';
import { escapeHtml, escapeAttr } from '../../utils/escape';
import { escapeHtml, escapeAttr, safeUrl } from '../../utils/escape';
interface PricingPlan {
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">
${featuresHtml}
</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>`;
}).join('\n ');
@@ -1,5 +1,5 @@
import React, { CSSProperties } from 'react';
import { escapeHtml, escapeAttr } from '../../utils/escape';
import { escapeHtml, escapeAttr, safeUrl } from '../../utils/escape';
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 {
return ctas.map((c) => {
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('');
}
@@ -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).
*/
import { escapeAttr } from './escape';
import { escapeAttr, safeUrl } from './escape';
export interface RelayWiring {
/** true when a recipient is set (relay path); false = legacy formAction fallback */
@@ -33,7 +33,7 @@ export function relayFormWiring(
fallbackAction: string | undefined,
): RelayWiring {
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);
return {