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'>${tag}>`;
+
+ 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 = '">