Harden email HTML URL sanitization (#2496)

This commit is contained in:
Vykos
2026-06-04 20:47:47 +02:00
committed by GitHub
parent 01c99c3990
commit e113c10d01
2 changed files with 141 additions and 12 deletions

View File

@@ -30,6 +30,28 @@ export function _esc(text) {
return div.innerHTML;
}
function _attrEsc(text) {
return String(text ?? '')
.replace(/"/g, '"')
.replace(/'/g, ''')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/`/g, '&#96;');
}
function _compactUrlSchemeValue(value) {
return String(value || '').replace(/[\u0000-\u0020\u007f-\u009f]+/g, '').toLowerCase();
}
function _isDangerousUrl(value) {
const compact = _compactUrlSchemeValue(value);
return compact.startsWith('javascript:') || compact.startsWith('vbscript:') || compact.startsWith('data:');
}
function _isDangerousSrcset(value) {
return String(value || '').split(',').some(candidate => _isDangerousUrl(candidate));
}
// Escape + linkify URLs and email addresses. Returns innerHTML-safe markup.
export function _escLinkify(text) {
const escaped = _esc(text);
@@ -39,9 +61,9 @@ export function _escLinkify(text) {
return escaped
.replace(urlRe, (m) => {
const href = m.startsWith('www.') ? `https://${m}` : m;
return `<a href="${href}" target="_blank" rel="noopener noreferrer">${m}</a>`;
return `<a href="${_attrEsc(href)}" target="_blank" rel="noopener noreferrer">${m}</a>`;
})
.replace(mailRe, (m) => `<a href="mailto:${m}">${m}</a>`);
.replace(mailRe, (m) => `<a href="${_attrEsc(`mailto:${m}`)}">${m}</a>`);
}
// Pull display name out of "Name <email@x>"; fallback to local-part of
@@ -133,19 +155,14 @@ export function _initials(s) {
// `data:` URLs on every known URL attribute, scrubs inline colour/font/
// position styles so the theme can take over, and wraps highlight-bearing
// inline tags in <mark> so they render legibly across themes.
export function _sanitizeHtml(html) {
function _sanitizeHtmlOnce(html) {
const doc = new DOMParser().parseFromString(html, 'text/html');
doc.querySelectorAll(
'script, iframe, object, embed, form, style, link, ' +
'svg, math, base, meta, noscript, frame, frameset, applet, portal'
).forEach(el => el.remove());
const URL_ATTRS = ['href', 'src', 'srcset', 'action', 'formaction', 'background', 'poster', 'data'];
const isDangerousUrl = (val) => {
if (!val) return false;
const v = val.trim().toLowerCase();
return v.startsWith('javascript:') || v.startsWith('vbscript:') || v.startsWith('data:');
};
const URL_ATTRS = ['href', 'src', 'xlink:href', 'srcset', 'action', 'formaction', 'background', 'poster', 'data'];
const STRIP_CSS_PROPS = ['color', 'background', 'background-color',
'font-family', 'font', '-webkit-text-fill-color',
@@ -160,7 +177,7 @@ export function _sanitizeHtml(html) {
const name = attr.name.toLowerCase();
if (name.startsWith('on')) { el.removeAttribute(attr.name); continue; }
if (name === 'srcdoc') { el.removeAttribute(attr.name); continue; }
if (URL_ATTRS.includes(name) && isDangerousUrl(attr.value)) {
if (URL_ATTRS.includes(name) && (name === 'srcset' ? _isDangerousSrcset(attr.value) : _isDangerousUrl(attr.value))) {
el.removeAttribute(attr.name);
continue;
}
@@ -177,8 +194,8 @@ export function _sanitizeHtml(html) {
if (style) {
const kept = style.split(';').map(s => s.trim()).filter(decl => {
if (!decl) return false;
const lower = decl.toLowerCase();
if (lower.includes('javascript:') || lower.includes('expression(')) return false;
const lower = _compactUrlSchemeValue(decl);
if (lower.includes('javascript:') || lower.includes('vbscript:') || lower.includes('data:') || lower.includes('expression(')) return false;
const prop = decl.split(':', 1)[0].trim().toLowerCase();
return !STRIP_CSS_PROPS.includes(prop);
});
@@ -200,3 +217,13 @@ export function _sanitizeHtml(html) {
return doc.body.innerHTML;
}
export function _sanitizeHtml(html) {
let out = String(html ?? '');
for (let i = 0; i < 4; i++) {
const next = _sanitizeHtmlOnce(out);
if (next === out) break;
out = next;
}
return out;
}