mirror of
https://github.com/zoriya/v10.git
synced 2026-08-05 05:37:21 +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
@@ -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="" onerror="window.__xss=1""></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=""><img src=x onerror="window.__xss=1">"></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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); }`,
|
||||
``,
|
||||
`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}\`);`,
|
||||
`}`,
|
||||
|
||||
@@ -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="" onerror="alert(1)"');
|
||||
});
|
||||
|
||||
it('escapes angle brackets in attribute values', () => {
|
||||
expect(serializeAttributes({ src: '"><script>bad</script><video x="' })).toBe(
|
||||
' src=""><script>bad</script><video x=""'
|
||||
);
|
||||
});
|
||||
|
||||
it('escapes ampersands in attribute values', () => {
|
||||
expect(serializeAttributes({ src: 'a&b' })).toBe(' src="a&b"');
|
||||
});
|
||||
|
||||
it('escapes ampersand before other entities to prevent double-encoding', () => {
|
||||
// If '&' were escaped after '"', the existing '"' would become '&quot;'
|
||||
// which the browser would decode as the literal text '"' instead of '"'.
|
||||
// This test ensures the pre-existing entity reference is preserved correctly.
|
||||
expect(serializeAttributes({ src: 'x"y' })).toBe(' src="x&quot;y"');
|
||||
});
|
||||
|
||||
it('escapes all four special characters together', () => {
|
||||
const value = '&"<>';
|
||||
expect(serializeAttributes({ src: value })).toBe(' src="&"<>"');
|
||||
});
|
||||
|
||||
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,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, '&')
|
||||
|
||||
@@ -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('&')).toBe('&amp;');
|
||||
expect(escapeHtml('https://player.vimeo.com/video/123?autoplay=1')).toBe(
|
||||
'https://player.vimeo.com/video/123?autoplay=1'
|
||||
);
|
||||
|
||||
@@ -773,18 +773,18 @@ function evaluateTemplate(templateBody: string, context: Record<string, unknown>
|
||||
.trim();
|
||||
}
|
||||
|
||||
function escapeAttributeValue(value: string): string {
|
||||
return value.replaceAll('&', '&').replaceAll('"', '"');
|
||||
function escapeHtml(value: string): string {
|
||||
return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"');
|
||||
}
|
||||
|
||||
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>`;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user