fix(packages): escape HTML special chars in serializeAttributes to prevent XSS (#1670)

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Wesley Luyten <me@wesleyluyten.com>
This commit is contained in:
Manuel Calleriza
2026-06-18 12:23:19 -07:00
committed by GitHub
co-authored by Claude Sonnet 4.6 Wesley Luyten
parent 9170a5879e
commit accf4bfa34
11 changed files with 322 additions and 38 deletions
+62
View File
@@ -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 = '<hls-video crossorigin="&quot; onerror=&quot;window.__xss=1&quot;"></hls-video>';
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 =
'<hls-video crossorigin="&quot;&gt;&lt;img src=x onerror=&quot;window.__xss=1&quot;&gt;"></hls-video>';
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 = '<hls-video crossorigin="anonymous"></hls-video>';
const el = container.querySelector('hls-video')!;
const video = el.shadowRoot?.querySelector('video');
return { crossorigin: video?.getAttribute('crossorigin') };
});
expect(result.crossorigin).toBe('anonymous');
});
});
@@ -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 = '"><script>window.__xss=1</script><video x="';
// JSDOM shadow DOM has parsing quirks; test getTemplateHTML directly in a plain container.
const container = document.createElement('div');
container.innerHTML = (Ctor as any).getTemplateHTML({ crossorigin: maliciousValue });
expect(container.querySelectorAll('script')).toHaveLength(0);
expect(container.querySelectorAll('img[onerror]')).toHaveLength(0);
expect((globalThis as any).__xss).toBeUndefined();
});
it('does not inject img elements when poster contains an angle-bracket payload', () => {
const { Ctor } = defineVideoElement();
const maliciousValue = '"><img src=x onerror="window.__xss=1">';
// JSDOM shadow DOM has parsing quirks; test getTemplateHTML directly in a plain container.
const container = document.createElement('div');
container.innerHTML = (Ctor as any).getTemplateHTML({ poster: maliciousValue });
expect(container.querySelectorAll('img')).toHaveLength(0);
expect((globalThis as any).__xss).toBeUndefined();
});
it('preserves the attribute value correctly after escaping', () => {
const { tag } = defineVideoElement();
const container = document.createElement('div');
document.body.appendChild(container);
container.innerHTML = `<${tag} poster="https://example.com/poster.jpg"></${tag}>`;
const el = container.querySelector(tag)!;
const video = el.shadowRoot!.querySelector('video')!;
expect(video.getAttribute('poster')).toBe('https://example.com/poster.jpg');
});
});
});
+3 -3
View File
@@ -1,11 +1,11 @@
import { ReactiveElement } from '@videojs/element';
import { ensureGlobalStyle, namedNodeMapToObject } from '@videojs/utils/dom';
import { ensureGlobalStyle } from '@videojs/utils/dom';
import { safeDefine } from '../safe-define';
import styles from './skin.css?inline';
const STYLES_ID = '__media-background-styles';
function getTemplateHTML(_attrs: Record<string, string>) {
function getTemplateHTML() {
return /*html*/ `
<media-container>
<!-- @deprecated slot="media" is no longer required, use the default slot instead -->
@@ -27,7 +27,7 @@ export class BackgroundVideoSkinElement extends ReactiveElement {
if (!this.shadowRoot) {
this.attachShadow((this.constructor as typeof BackgroundVideoSkinElement).shadowRootOptions);
this.shadowRoot!.innerHTML = getTemplateHTML(namedNodeMapToObject(this.attributes));
this.shadowRoot!.innerHTML = getTemplateHTML();
}
}
}
@@ -1,7 +1,21 @@
import type { Media } from '@videojs/core/dom';
import { namedNodeMapToObject } from '@videojs/utils/dom';
import { namedNodeMapToObject, serializeAttributes } from '@videojs/utils/dom';
import { pick } from '@videojs/utils/object';
import { MediaAttachMixin } from '../../store/media-attach-mixin';
const VideoAttributes = [
'autoplay',
'controls',
'controlslist',
'crossorigin',
'disablepictureinpicture',
'disableremoteplayback',
'loop',
'muted',
'playsinline',
'preload',
] as const;
function getTemplateHTML(attrs: Record<string, string>) {
return /*html*/ `
<style>
@@ -19,7 +33,7 @@ function getTemplateHTML(attrs: Record<string, string>) {
}
</style>
<slot></slot>
<video ${serializeAttributes(attrs)}></video>
<video${serializeAttributes(pick(attrs, [...VideoAttributes]))}></video>
`;
}
@@ -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, string>): 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;
}
@@ -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<string, string> = {}): Element {
const container = document.createElement('div');
document.body.appendChild(container);
const attrStr = Object.entries(attrs)
.map(
([k, v]) =>
` ${k}="${v.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')}"`
)
.join('');
container.innerHTML = `<${tag}${attrStr}></${tag}>`;
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: '"><script>window.__xss=1</script><video x="' });
const shadow = el.shadowRoot!;
expect(shadow.querySelectorAll('script')).toHaveLength(0);
expect((globalThis as any).__xss).toBeUndefined();
});
it('does not inject img elements via controlslist value', () => {
const tag = defineElement();
const el = create(tag, { controlslist: '"><img src=x onerror="window.__xss=1">' });
const shadow = el.shadowRoot!;
expect(shadow.querySelectorAll('img')).toHaveLength(0);
expect((globalThis as any).__xss).toBeUndefined();
});
it('does not forward non-whitelisted attributes into the shadow template', () => {
const tag = defineElement();
const el = create(tag, { 'data-x': '" onerror="window.__xss=1' });
const shadow = el.shadowRoot!;
const video = shadow.querySelector('video')!;
expect(video.hasAttribute('data-x')).toBe(false);
expect(shadow.querySelectorAll('[onerror]')).toHaveLength(0);
});
it('preserves safe whitelisted attribute values correctly', () => {
const tag = defineElement();
// Test template generation directly — happy-dom may return null for IDL attrs (crossorigin) on shadow DOM elements.
const Ctor = customElements.get(tag) as typeof BackgroundVideo;
const attrs = {
crossorigin: 'anonymous',
preload: 'metadata',
muted: '',
loop: '',
autoplay: '',
playsinline: '',
disableremoteplayback: '',
disablepictureinpicture: '',
};
const container = document.createElement('div');
container.innerHTML = (Ctor as any).getTemplateHTML(attrs);
const video = container.querySelector('video')!;
expect(video.getAttribute('crossorigin')).toBe('anonymous');
expect(video.getAttribute('preload')).toBe('metadata');
});
it('serializes boolean (empty-string) attributes without a value', () => {
const tag = defineElement();
const el = create(tag);
const video = el.shadowRoot!.querySelector('video')!;
// muted/loop/autoplay/playsinline are set as booleans by the constructor.
expect(video.hasAttribute('muted')).toBe(true);
expect(video.hasAttribute('loop')).toBe(true);
expect(video.hasAttribute('autoplay')).toBe(true);
expect(video.hasAttribute('playsinline')).toBe(true);
});
});
});
+3 -1
View File
@@ -72,12 +72,14 @@ function buildRenderModule(icons: { name: string; content: string }[]): string {
return [
`const icons = {\n${entries},\n};`,
``,
`function esc(v) { return String(v).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); }`,
``,
`export function renderIcon(name, attrs) {`,
` const svg = icons[name];`,
` if (!svg) return '';`,
` if (!attrs) return svg;`,
` const attrStr = Object.entries(attrs)`,
` .map(([k, v]) => \` \${k}="\${v}"\`)`,
` .map(([k, v]) => \` \${k}="\${esc(v)}"\`)`,
` .join('');`,
` return svg.replace('<svg', \`<svg\${attrStr}\`);`,
`}`,
+4 -2
View File
@@ -1,3 +1,5 @@
import { escapeHtml } from '../string/escape-html';
/**
* Convert a NamedNodeMap to a plain object.
*/
@@ -15,9 +17,9 @@ export function namedNodeMapToObject(namedNodeMap: NamedNodeMap) {
export function serializeAttributes(attrs: Record<string, string>) {
let html = '';
for (const key in attrs) {
const value = attrs[key];
const value = attrs[key]!;
if (value === '') html += ` ${key}`;
else html += ` ${key}="${value}"`;
else html += ` ${key}="${escapeHtml(value)}"`;
}
return html;
}
@@ -0,0 +1,66 @@
import { describe, expect, it } from 'vitest';
import { namedNodeMapToObject, serializeAttributes } from '../attributes';
describe('serializeAttributes', () => {
it('serializes a boolean (empty-string) attribute without a value', () => {
expect(serializeAttributes({ muted: '' })).toBe(' muted');
});
it('serializes a normal value unchanged when no special characters are present', () => {
expect(serializeAttributes({ preload: 'metadata' })).toBe(' preload="metadata"');
});
it('serializes multiple attributes', () => {
expect(serializeAttributes({ autoplay: '', preload: 'metadata' })).toBe(' autoplay preload="metadata"');
});
it('escapes double quotes in attribute values', () => {
expect(serializeAttributes({ src: '" onerror="alert(1)' })).toBe(' src="&quot; onerror=&quot;alert(1)"');
});
it('escapes angle brackets in attribute values', () => {
expect(serializeAttributes({ src: '"><script>bad</script><video x="' })).toBe(
' src="&quot;&gt;&lt;script&gt;bad&lt;/script&gt;&lt;video x=&quot;"'
);
});
it('escapes ampersands in attribute values', () => {
expect(serializeAttributes({ src: 'a&b' })).toBe(' src="a&amp;b"');
});
it('escapes ampersand before other entities to prevent double-encoding', () => {
// If '&' were escaped after '"', the existing '&quot;' would become '&amp;quot;'
// which the browser would decode as the literal text '&quot;' instead of '"'.
// This test ensures the pre-existing entity reference is preserved correctly.
expect(serializeAttributes({ src: 'x&quot;y' })).toBe(' src="x&amp;quot;y"');
});
it('escapes all four special characters together', () => {
const value = '&"<>';
expect(serializeAttributes({ src: value })).toBe(' src="&amp;&quot;&lt;&gt;"');
});
it('returns an empty string for an empty object', () => {
expect(serializeAttributes({})).toBe('');
});
});
describe('namedNodeMapToObject', () => {
it('copies raw attribute values without escaping', () => {
const el = document.createElement('div');
el.setAttribute('src', '"raw"');
el.setAttribute('muted', '');
const result = namedNodeMapToObject(el.attributes);
expect(result.src).toBe('"raw"');
expect(result.muted).toBe('');
});
it('returns an empty object when there are no attributes', () => {
const el = document.createElement('div');
expect(namedNodeMapToObject(el.attributes)).toEqual({});
});
});
+1
View File
@@ -1,3 +1,4 @@
// Ampersand must be escaped first to avoid double-encoding the entities below.
export function escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
@@ -7,6 +7,11 @@ describe('escapeHtml', () => {
});
it('preserves strings without HTML special characters', () => {
expect(escapeHtml('https://example.com/video/123?autoplay=1')).toBe('https://example.com/video/123?autoplay=1');
});
it('escapes ampersand first to avoid double-encoding', () => {
expect(escapeHtml('&amp;')).toBe('&amp;amp;');
expect(escapeHtml('https://player.vimeo.com/video/123?autoplay=1')).toBe(
'https://player.vimeo.com/video/123?autoplay=1'
);
+4 -4
View File
@@ -773,18 +773,18 @@ function evaluateTemplate(templateBody: string, context: Record<string, unknown>
.trim();
}
function escapeAttributeValue(value: string): string {
return value.replaceAll('&', '&amp;').replaceAll('"', '&quot;');
function escapeHtml(value: string): string {
return value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;');
}
function createRenderMediaIcon(iconSet: 'default' | 'minimal') {
return (name: string, attrs?: Record<string, string>): string => {
const family = iconSet === 'minimal' ? ' family="minimal"' : '';
const attrText = Object.entries(attrs ?? {})
.map(([key, value]) => ` ${key}="${escapeAttributeValue(value)}"`)
.map(([key, value]) => ` ${key}="${escapeHtml(value)}"`)
.join('');
return `<media-icon name="${escapeAttributeValue(name)}"${family}${attrText}></media-icon>`;
return `<media-icon name="${escapeHtml(name)}"${family}${attrText}></media-icon>`;
};
}