An adversarial pass found 5 Critical XSS sinks where props declared number/enum in TypeScript were interpolated raw into exported HTML attribute values, trusting the type — but nothing enforces it at runtime (AI update_props only validates node_id; deserialized saved state is untyped JSON). Fixed all 5 (NumberCounter data-target, StarRating aria-label, FormContainer method, ContactForm/InputField input type) plus 6 sibling sinks found by an exhaustive audit of every attribute-value interpolation across src/components: a JS-source injection into ContentSlider's inline setInterval script, a prototype-pollution-adjacent allowlist gap in Section's divider-shape lookup, TextareaField rows, Testimonials rating aria-label, HeroSimple textAlign, and MapEmbed zoom. Adds shared sanitizeFormMethod/sanitizeInputType allowlist helpers to utils/escape.ts alongside the existing escapeAttr/safeUrl/cssValue primitives. Every fix is TDD'd: a malicious-value test reproduces the raw injection against the pre-fix code, then passes after the fix. 502 tests green (npx vitest run), tsc + vite build green (npm run build). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
121 lines
5.5 KiB
TypeScript
121 lines
5.5 KiB
TypeScript
import { describe, test, expect } from 'vitest';
|
|
import { VideoBlock } from './VideoBlock';
|
|
|
|
const toHtml = (VideoBlock as any).toHtml;
|
|
|
|
function embedSrc(videoUrl: string): string {
|
|
const { html } = toHtml({ videoUrl }, '');
|
|
const m = html.match(/<iframe src="([^"]+)"/) || html.match(/<video src="([^"]+)"/);
|
|
return m ? m[1].replace(/&/g, '&') : '';
|
|
}
|
|
|
|
describe('VideoBlock URL parsing (D4)', () => {
|
|
test('youtube.com/watch?v=ID (existing case) resolves to embed URL', () => {
|
|
expect(embedSrc('https://www.youtube.com/watch?v=dQw4w9WgXcQ')).toContain('youtube.com/embed/dQw4w9WgXcQ');
|
|
});
|
|
|
|
test('youtu.be/ID resolves to embed URL', () => {
|
|
expect(embedSrc('https://youtu.be/dQw4w9WgXcQ')).toContain('youtube.com/embed/dQw4w9WgXcQ');
|
|
});
|
|
|
|
test('youtube.com/embed/ID (existing case) resolves to embed URL', () => {
|
|
expect(embedSrc('https://www.youtube.com/embed/dQw4w9WgXcQ')).toContain('youtube.com/embed/dQw4w9WgXcQ');
|
|
});
|
|
|
|
test('youtube.com/shorts/ID resolves to embed URL', () => {
|
|
expect(embedSrc('https://www.youtube.com/shorts/dQw4w9WgXcQ')).toContain('youtube.com/embed/dQw4w9WgXcQ');
|
|
});
|
|
|
|
test('youtube.com/live/ID resolves to embed URL', () => {
|
|
expect(embedSrc('https://www.youtube.com/live/dQw4w9WgXcQ')).toContain('youtube.com/embed/dQw4w9WgXcQ');
|
|
});
|
|
|
|
test('youtube.com/watch?...&v=ID (v not first param) resolves to embed URL', () => {
|
|
expect(embedSrc('https://www.youtube.com/watch?list=PLxyz&v=dQw4w9WgXcQ&index=3')).toContain('youtube.com/embed/dQw4w9WgXcQ');
|
|
});
|
|
|
|
test('vimeo.com/ID (existing case) resolves to player URL', () => {
|
|
expect(embedSrc('https://vimeo.com/123456789')).toContain('https://player.vimeo.com/video/123456789');
|
|
});
|
|
|
|
test('vimeo.com/ID/HASH (private video) resolves to player URL with hash param', () => {
|
|
const src = embedSrc('https://vimeo.com/123456789/abcdef1234');
|
|
expect(src).toContain('https://player.vimeo.com/video/123456789');
|
|
expect(src).toContain('h=abcdef1234');
|
|
});
|
|
|
|
test('direct .mp4 file still works', () => {
|
|
const { html } = toHtml({ videoUrl: 'https://example.com/clip.mp4' }, '');
|
|
expect(html).toContain('<video src="https://example.com/clip.mp4"');
|
|
});
|
|
|
|
test('unrecognized URL yields no output (type "none")', () => {
|
|
const { html } = toHtml({ videoUrl: 'not-a-real-video-url' }, '');
|
|
expect(html).toBe('');
|
|
});
|
|
|
|
test('emitted src is safeUrl-wrapped: javascript: scheme never reaches output', () => {
|
|
const { html } = toHtml({ videoUrl: 'javascript:alert(1)' }, '');
|
|
expect(html).not.toContain('javascript:');
|
|
});
|
|
});
|
|
|
|
describe('VideoBlock.toHtml iframe accessibility (F2.4)', () => {
|
|
test('normal-mode YouTube/Vimeo iframe has a title attribute', () => {
|
|
const { html } = toHtml({ videoUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ' }, '');
|
|
expect(html).toMatch(/<iframe[^>]*title="[^"]+"/);
|
|
});
|
|
|
|
test('background-mode YouTube/Vimeo iframe has a title attribute', () => {
|
|
const { html } = toHtml({ videoUrl: 'https://vimeo.com/123456789', isBackground: true }, '');
|
|
expect(html).toMatch(/<iframe[^>]*title="[^"]+"/);
|
|
});
|
|
});
|
|
|
|
describe('VideoBlock.toHtml overlay/innerMaxWidth XSS hardening (background mode)', () => {
|
|
test('a malicious overlayColor cannot break out of the overlay style attribute', () => {
|
|
const malicious = 'red" onmouseover="alert(1)';
|
|
const { html } = toHtml({ videoUrl: 'https://vimeo.com/123456789', isBackground: true, overlayColor: malicious }, '');
|
|
expect(html).not.toContain('onmouseover="alert(1)"');
|
|
});
|
|
|
|
test('a wrong-typed overlayOpacity (string, not number) cannot break out of the overlay style attribute', () => {
|
|
const malicious = '50" onmouseover="alert(1)' as any;
|
|
const { html } = toHtml({ videoUrl: 'https://vimeo.com/123456789', isBackground: true, overlayOpacity: malicious }, '');
|
|
expect(html).not.toContain('onmouseover="alert(1)"');
|
|
});
|
|
|
|
test('a malicious innerMaxWidth cannot break out of the inner style attribute', () => {
|
|
const malicious = '1200px" onmouseover="alert(1)';
|
|
const { html } = toHtml({ videoUrl: 'https://vimeo.com/123456789', isBackground: true, innerMaxWidth: malicious }, '');
|
|
expect(html).not.toContain('onmouseover="alert(1)"');
|
|
});
|
|
|
|
test('a malicious style.borderRadius cannot break out of the style attribute (normal mode, iframe wrapper)', () => {
|
|
const malicious = { borderRadius: '8px" onmouseover="alert(1)' } as any;
|
|
const { html } = toHtml({ videoUrl: 'https://vimeo.com/123456789', style: malicious }, '');
|
|
expect(html).not.toContain('onmouseover="alert(1)"');
|
|
});
|
|
|
|
test('a malicious style.borderRadius cannot break out of the style attribute (direct file <video>)', () => {
|
|
const malicious = { borderRadius: '8px" onmouseover="alert(1)' } as any;
|
|
const { html } = toHtml({ videoUrl: 'https://example.com/clip.mp4', style: malicious }, '');
|
|
expect(html).not.toContain('onmouseover="alert(1)"');
|
|
});
|
|
});
|
|
|
|
describe('VideoBlock.toHtml iframe src ampersand encoding (F-export review Minor)', () => {
|
|
test('embed params joined with literal & are HTML-entity-encoded in the emitted src attribute', () => {
|
|
// autoplay+muted+controls=false forces buildEmbedParams to concatenate
|
|
// multiple query params onto the URL with literal `&`s.
|
|
const { html } = toHtml(
|
|
{ videoUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', autoplay: true, muted: true, controls: false },
|
|
''
|
|
);
|
|
const srcMatch = html.match(/<iframe src="([^"]+)"/);
|
|
expect(srcMatch).toBeTruthy();
|
|
expect(srcMatch![1]).toMatch(/&/);
|
|
expect(srcMatch![1]).not.toMatch(/&(?!amp;)/);
|
|
});
|
|
});
|