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