Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 21 additions & 2 deletions src/security/sanitize-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,23 @@ const isAllowedAttribute = (
return allowedSet.has(lowerName);
};

/**
* Escape HTML entities so text is inert when assigned to an HTML sink.
* Local copy to avoid a circular import with `sanitize.ts`.
* @internal
*/
const escapeHtmlText = (text: string): string => {
const escapeMap: Record<string, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#x27;',
'`': '&#x60;',
};
return text.replace(/[&<>"'`]/g, (char) => escapeMap[char]);
};

/**
* Check if an ID/name value could cause DOM clobbering.
* @internal
Expand Down Expand Up @@ -356,8 +373,10 @@ export const sanitizeHtmlCore = (html: string, options: SanitizeOptions = {}): s
// Verify stability: if content mutates between parses, it indicates mXSS attempt
if (firstPass !== secondPass) {
// Content mutated during re-parse - potential mXSS detected.
// Return safely escaped text content as fallback.
return fragment.textContent ?? '';
// Callers assign this return value to HTML sinks (innerHTML etc.), so the
// text fallback must be HTML-escaped: entity-decoded text nodes can contain
// live markup (e.g. `&lt;img onerror=...&gt;` decoded to `<img onerror=...>`).
return escapeHtmlText(fragment.textContent ?? '');
}

return secondPass;
Expand Down
13 changes: 13 additions & 0 deletions tests/security.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,19 @@ describe('security/sanitizeHtml', () => {
});
expect(result).not.toContain('data-secret');
});

it('escapes the mXSS-detection text fallback (entity-smuggled markup stays inert)', () => {
// Foster-parenting makes serialize→re-parse unstable, forcing the mXSS
// fallback branch; the entity-encoded payload decodes to live markup in
// textContent and must not survive as executable HTML.
const payload = '<a><table><a>&lt;img src=x onerror=alert(1)&gt;';
const result = String(sanitizeHtml(payload));
expect(result).not.toContain('<img');

const host = document.createElement('div');
host.innerHTML = result;
expect(host.querySelectorAll('img').length).toBe(0);
});
});

describe('security/escapeHtml', () => {
Expand Down