fix: prevent XSS via quote injection in plain-text email linkifier

This commit is contained in:
Linus Rath
2026-04-18 00:35:21 +02:00
parent 850ee73048
commit f05f70a9e5
5 changed files with 98 additions and 36 deletions
+52
View File
@@ -5,6 +5,7 @@ import {
sanitizeSignatureHtml,
parseHtmlSafely,
hasRichFormatting,
plainTextToSafeHtml,
EMAIL_SANITIZE_CONFIG,
} from '../email-sanitization';
@@ -252,4 +253,55 @@ describe('email-sanitization', () => {
expect(clean).toContain('data:image/gif');
});
});
describe('plainTextToSafeHtml', () => {
it('escapes HTML-special characters in surrounding text', () => {
const result = plainTextToSafeHtml('<script>alert(1)</script> & "q" \'q\'');
expect(result).not.toContain('<script>');
expect(result).toContain('&lt;script&gt;');
expect(result).toContain('&amp;');
expect(result).toContain('&quot;');
expect(result).toContain('&#39;');
});
it('linkifies http(s) URLs', () => {
const result = plainTextToSafeHtml('visit http://example.com/path now');
expect(result).toContain('<a href="http://example.com/path"');
expect(result).toContain('target="_blank"');
expect(result).toContain('rel="noopener noreferrer"');
});
it('prevents attribute breakout via quote in URL (CVE regression)', () => {
const payload = 'http://evil.tld/"onmouseover="alert(1)"x="';
const result = plainTextToSafeHtml(payload);
// The anchor tag must not contain any unescaped attribute beyond href/target/rel.
expect(result).not.toMatch(/<a [^>]*onmouseover/i);
expect(result).not.toMatch(/<a [^>]*style=/i);
// Quotes from the payload must be entity-encoded wherever they land.
expect(result).toContain('&quot;');
});
it('prevents attribute breakout via style injection', () => {
const payload = 'http://evil.tld/"style="background:red"x="';
const result = plainTextToSafeHtml(payload);
expect(result).not.toMatch(/href="[^"]*"[^>]*style=/);
});
it('terminates URL at quote, keeping rest as escaped text', () => {
const result = plainTextToSafeHtml('http://evil.tld/"injected');
expect(result).toContain('<a href="http://evil.tld/"');
expect(result).toContain('&quot;injected');
});
it('applies linkClass when provided and escapes it', () => {
const result = plainTextToSafeHtml('http://x.com', 'text-primary hover:underline');
expect(result).toContain('class="text-primary hover:underline"');
});
it('does not linkify non-http schemes', () => {
const result = plainTextToSafeHtml('try javascript:alert(1) or file:///etc/passwd');
expect(result).not.toContain('<a ');
expect(result).toContain('javascript:alert(1)');
});
});
});
+34
View File
@@ -80,6 +80,40 @@ export function hasRichFormatting(html: string): boolean {
);
}
const HTML_ESCAPES: Record<string, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
};
function escapeHtml(str: string): string {
return str.replace(/[&<>"']/g, (c) => HTML_ESCAPES[c]);
}
/**
* Render a plain-text email body as HTML, HTML-escaping all content and
* linkifying http(s) URLs. URLs terminate at whitespace or any character that
* would break an attribute (`"`, `'`, `<`, `>`), so attribute-escaping is
* enforced even if escaping has bugs.
*/
export function plainTextToSafeHtml(text: string, linkClass = ''): string {
const urlRegex = /(https?:\/\/[^\s<>"']+)/g;
const classAttr = linkClass ? ` class="${escapeHtml(linkClass)}"` : '';
let result = '';
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = urlRegex.exec(text)) !== null) {
result += escapeHtml(text.slice(lastIndex, match.index));
const url = escapeHtml(match[0]);
result += `<a href="${url}" target="_blank" rel="noopener noreferrer"${classAttr}>${url}</a>`;
lastIndex = match.index + match[0].length;
}
result += escapeHtml(text.slice(lastIndex));
return result;
}
/**
* Collapse empty containers left behind when external images are blocked.
* Walks up from each blocked img to find the nearest table cell or wrapper div