Files
site-builder/craft/src/components/toHtml-url-safety.test.ts
T
shadowdao 48d0441be3 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>
2026-07-12 12:05:57 -07:00

160 lines
7.9 KiB
TypeScript

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"');
});
});