mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
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:
co-authored by
Claude Sonnet 4.6
Wesley Luyten
parent
9170a5879e
commit
accf4bfa34
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')}"`
|
||||
)
|
||||
.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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user