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:
@@ -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"');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user