diff --git a/apps/e2e/tests/xss-prevention.spec.ts b/apps/e2e/tests/xss-prevention.spec.ts new file mode 100644 index 00000000..ffc81a74 --- /dev/null +++ b/apps/e2e/tests/xss-prevention.spec.ts @@ -0,0 +1,62 @@ +import { expect, test } from '@playwright/test'; + +test.describe('XSS prevention — CustomMediaElement shadow root', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/pages/html-video-hls.html'); + // Wait for hls-video to be defined before creating elements dynamically. + await page.waitForFunction(() => !!customElements.get('hls-video')); + }); + + test('quote injection in crossorigin does not fire onerror handler', async ({ page }) => { + // innerHTML on a connected container: attributes are present when the constructor runs. + const result = await page.evaluate(() => { + (window as any).__xss = undefined; + const container = document.createElement('div'); + document.body.appendChild(container); + container.innerHTML = ''; + const el = container.querySelector('hls-video')!; + return { + xss: (window as any).__xss, + hasOnerror: el.shadowRoot?.querySelector('[onerror]') !== null, + hasUpgraded: el.shadowRoot !== null, + }; + }); + + expect(result.hasUpgraded).toBe(true); + expect(result.xss).toBeUndefined(); + expect(result.hasOnerror).toBe(false); + }); + + test('angle-bracket injection in crossorigin does not inject sibling elements', async ({ page }) => { + const result = await page.evaluate(() => { + (window as any).__xss = undefined; + const container = document.createElement('div'); + document.body.appendChild(container); + container.innerHTML = + ''; + const el = container.querySelector('hls-video')!; + return { + xss: (window as any).__xss, + hasImg: el.shadowRoot?.querySelector('img') !== null, + hasScript: el.shadowRoot?.querySelector('script') !== null, + }; + }); + + expect(result.xss).toBeUndefined(); + expect(result.hasImg).toBe(false); + expect(result.hasScript).toBe(false); + }); + + test('safe attribute values are preserved correctly after escaping', async ({ page }) => { + const result = await page.evaluate(() => { + const container = document.createElement('div'); + document.body.appendChild(container); + container.innerHTML = ''; + const el = container.querySelector('hls-video')!; + const video = el.shadowRoot?.querySelector('video'); + return { crossorigin: video?.getAttribute('crossorigin') }; + }); + + expect(result.crossorigin).toBe('anonymous'); + }); +}); diff --git a/packages/core/src/dom/media/custom-media-element/tests/custom-media-element.test.ts b/packages/core/src/dom/media/custom-media-element/tests/custom-media-element.test.ts index 6b9d430c..5d600cd0 100644 --- a/packages/core/src/dom/media/custom-media-element/tests/custom-media-element.test.ts +++ b/packages/core/src/dom/media/custom-media-element/tests/custom-media-element.test.ts @@ -1036,4 +1036,57 @@ describe('CustomMediaElement', () => { expect(el.getAttribute('playback-id')).toBe('xyz789'); }); }); + + describe('XSS prevention', () => { + it('does not inject nodes when poster contains a quote breakout attempt', () => { + const { tag } = defineVideoElement(); + const container = document.createElement('div'); + document.body.appendChild(container); + container.innerHTML = `<${tag} poster='" onerror="window.__xss=1'>`; + + const el = container.querySelector(tag)!; + const shadow = el.shadowRoot!; + + expect(shadow.querySelectorAll('[onerror]')).toHaveLength(0); + expect(shadow.querySelectorAll('[onload]')).toHaveLength(0); + expect((globalThis as any).__xss).toBeUndefined(); + }); + + it('does not inject script elements when an attribute value contains angle brackets', () => { + const { Ctor } = defineVideoElement(); + const maliciousValue = '"> `; } @@ -75,29 +89,3 @@ export class BackgroundVideo extends MediaAttachMixin(HTMLElement) { return video instanceof HTMLVideoElement ? video : null; } } - -const VideoAttributes = [ - 'autoplay', - 'controls', - 'controlslist', - 'crossorigin', - 'disablepictureinpicture', - 'disableremoteplayback', - 'loop', - 'muted', - 'playsinline', - 'preload', -] as const; - -function serializeAttributes(attrs: Record): string { - let html = ''; - for (const key in attrs) { - // Skip forwarding non native video attributes. - if (!VideoAttributes.includes(key as any)) continue; - - const value = attrs[key]; - if (value === '') html += ` ${key}`; - else html += ` ${key}="${value}"`; - } - return html; -} diff --git a/packages/html/src/media/background-video/tests/background-video.test.ts b/packages/html/src/media/background-video/tests/background-video.test.ts new file mode 100644 index 00000000..5e7816a6 --- /dev/null +++ b/packages/html/src/media/background-video/tests/background-video.test.ts @@ -0,0 +1,105 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { BackgroundVideo } from '../index'; + +afterEach(() => { + document.body.innerHTML = ''; +}); + +let tagCounter = 0; + +function defineElement() { + const tag = `test-background-video-${++tagCounter}`; + customElements.define(tag, class extends BackgroundVideo {}); + return tag; +} + +// innerHTML on a connected container so attributes are present when the constructor runs. +function create(tag: string, attrs: Record = {}): Element { + const container = document.createElement('div'); + document.body.appendChild(container); + const attrStr = Object.entries(attrs) + .map( + ([k, v]) => + ` ${k}="${v.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"')}"` + ) + .join(''); + container.innerHTML = `<${tag}${attrStr}>`; + return container.querySelector(tag)!; +} + +describe('BackgroundVideo', () => { + describe('XSS prevention', () => { + it('does not inject nodes when a whitelisted attribute value contains a quote breakout', () => { + const tag = defineElement(); + const el = create(tag, { crossorigin: '" onerror="window.__xss=1' }); + const shadow = el.shadowRoot!; + + expect(shadow.querySelectorAll('[onerror]')).toHaveLength(0); + expect(shadow.querySelectorAll('[onload]')).toHaveLength(0); + expect((globalThis as any).__xss).toBeUndefined(); + }); + + it('does not inject script elements when a whitelisted attribute contains angle brackets', () => { + const tag = defineElement(); + const el = create(tag, { preload: '">