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,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'
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user