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
+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);
});
});
});