fix: prevent XSS via quote injection in plain-text email linkifier
This commit is contained in:
+5
-1
@@ -209,10 +209,14 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.email-content-text a {
|
.email-content-text a {
|
||||||
color: var(--color-primary);
|
color: #2563eb;
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dark .email-content-text a {
|
||||||
|
color: #60a5fa;
|
||||||
|
}
|
||||||
|
|
||||||
.email-content {
|
.email-content {
|
||||||
font-family:
|
font-family:
|
||||||
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
|
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback } fr
|
|||||||
import ReactDOM from "react-dom";
|
import ReactDOM from "react-dom";
|
||||||
import DOMPurify from "dompurify";
|
import DOMPurify from "dompurify";
|
||||||
import { Email, ContactCard, Mailbox } from "@/lib/jmap/types";
|
import { Email, ContactCard, Mailbox } from "@/lib/jmap/types";
|
||||||
import { EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers } from "@/lib/email-sanitization";
|
import { EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers, plainTextToSafeHtml } from "@/lib/email-sanitization";
|
||||||
import { hasMeaningfulHtmlBody } from "@/lib/signature-utils";
|
import { hasMeaningfulHtmlBody } from "@/lib/signature-utils";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Avatar } from "@/components/ui/avatar";
|
import { Avatar } from "@/components/ui/avatar";
|
||||||
@@ -2397,16 +2397,8 @@ export function EmailViewer({
|
|||||||
if (email.textBody?.[0]?.partId && email.bodyValues[email.textBody[0].partId]) {
|
if (email.textBody?.[0]?.partId && email.bodyValues[email.textBody[0].partId]) {
|
||||||
const textContent = email.bodyValues[email.textBody[0].partId].value;
|
const textContent = email.bodyValues[email.textBody[0].partId].value;
|
||||||
|
|
||||||
// Convert plain text to HTML with proper formatting
|
|
||||||
// Uses white-space: pre-wrap on the container to preserve newlines/whitespace
|
|
||||||
const htmlFromText = textContent
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>');
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
html: htmlFromText,
|
html: plainTextToSafeHtml(textContent),
|
||||||
isHtml: false
|
isHtml: false
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -2444,12 +2436,7 @@ export function EmailViewer({
|
|||||||
return { html: cleanHtml, isHtml: true };
|
return { html: cleanHtml, isHtml: true };
|
||||||
}
|
}
|
||||||
if (smimeDecryptedText) {
|
if (smimeDecryptedText) {
|
||||||
const htmlFromText = smimeDecryptedText
|
return { html: plainTextToSafeHtml(smimeDecryptedText), isHtml: false };
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>');
|
|
||||||
return { html: htmlFromText, isHtml: false };
|
|
||||||
}
|
}
|
||||||
// TNEF (winmail.dat) extracted content
|
// TNEF (winmail.dat) extracted content
|
||||||
if (tnefHtml) {
|
if (tnefHtml) {
|
||||||
@@ -2457,12 +2444,7 @@ export function EmailViewer({
|
|||||||
return { html: cleanHtml, isHtml: true };
|
return { html: cleanHtml, isHtml: true };
|
||||||
}
|
}
|
||||||
if (tnefText) {
|
if (tnefText) {
|
||||||
const htmlFromText = tnefText
|
return { html: plainTextToSafeHtml(tnefText), isHtml: false };
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>');
|
|
||||||
return { html: htmlFromText, isHtml: false };
|
|
||||||
}
|
}
|
||||||
// Embedded message/rfc822 unwrapped content
|
// Embedded message/rfc822 unwrapped content
|
||||||
if (embeddedEmailHtml) {
|
if (embeddedEmailHtml) {
|
||||||
@@ -2470,12 +2452,7 @@ export function EmailViewer({
|
|||||||
return { html: cleanHtml, isHtml: true };
|
return { html: cleanHtml, isHtml: true };
|
||||||
}
|
}
|
||||||
if (embeddedEmailText) {
|
if (embeddedEmailText) {
|
||||||
const htmlFromText = embeddedEmailText
|
return { html: plainTextToSafeHtml(embeddedEmailText), isHtml: false };
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>');
|
|
||||||
return { html: htmlFromText, isHtml: false };
|
|
||||||
}
|
}
|
||||||
return emailContent;
|
return emailContent;
|
||||||
}, [cidBlobUrls, emailContent, smimeDecryptedHtml, smimeDecryptedText, tnefHtml, tnefText, embeddedEmailHtml, embeddedEmailText]);
|
}, [cidBlobUrls, emailContent, smimeDecryptedHtml, smimeDecryptedText, tnefHtml, tnefText, embeddedEmailHtml, embeddedEmailText]);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useState, useEffect, useMemo } from "react";
|
import { useState, useEffect, useMemo } from "react";
|
||||||
import DOMPurify from "dompurify";
|
import DOMPurify from "dompurify";
|
||||||
import { Email, ThreadGroup } from "@/lib/jmap/types";
|
import { Email, ThreadGroup } from "@/lib/jmap/types";
|
||||||
import { EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers } from "@/lib/email-sanitization";
|
import { EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers, plainTextToSafeHtml } from "@/lib/email-sanitization";
|
||||||
import { hasMeaningfulHtmlBody } from "@/lib/signature-utils";
|
import { hasMeaningfulHtmlBody } from "@/lib/signature-utils";
|
||||||
import { transformInlineStyles, transformColorForDarkMode, transformBgColorForDarkMode } from "@/lib/color-transform";
|
import { transformInlineStyles, transformColorForDarkMode, transformBgColorForDarkMode } from "@/lib/color-transform";
|
||||||
import { useThemeStore } from "@/stores/theme-store";
|
import { useThemeStore } from "@/stores/theme-store";
|
||||||
@@ -419,12 +419,7 @@ function EmailCard({
|
|||||||
// Plain text fallback
|
// Plain text fallback
|
||||||
if (email.textBody?.[0]?.partId && email.bodyValues[email.textBody[0].partId]) {
|
if (email.textBody?.[0]?.partId && email.bodyValues[email.textBody[0].partId]) {
|
||||||
const text = email.bodyValues[email.textBody[0].partId].value;
|
const text = email.bodyValues[email.textBody[0].partId].value;
|
||||||
const htmlEscaped = text
|
return { html: plainTextToSafeHtml(text, 'text-primary hover:underline'), isHtml: false };
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1" target="_blank" rel="noopener noreferrer" class="text-primary hover:underline">$1</a>');
|
|
||||||
return { html: htmlEscaped, isHtml: false };
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
sanitizeSignatureHtml,
|
sanitizeSignatureHtml,
|
||||||
parseHtmlSafely,
|
parseHtmlSafely,
|
||||||
hasRichFormatting,
|
hasRichFormatting,
|
||||||
|
plainTextToSafeHtml,
|
||||||
EMAIL_SANITIZE_CONFIG,
|
EMAIL_SANITIZE_CONFIG,
|
||||||
} from '../email-sanitization';
|
} from '../email-sanitization';
|
||||||
|
|
||||||
@@ -252,4 +253,55 @@ describe('email-sanitization', () => {
|
|||||||
expect(clean).toContain('data:image/gif');
|
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('<script>');
|
||||||
|
expect(result).toContain('&');
|
||||||
|
expect(result).toContain('"');
|
||||||
|
expect(result).toContain(''');
|
||||||
|
});
|
||||||
|
|
||||||
|
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('"');
|
||||||
|
});
|
||||||
|
|
||||||
|
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('"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)');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -80,6 +80,40 @@ export function hasRichFormatting(html: string): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const HTML_ESCAPES: Record<string, string> = {
|
||||||
|
'&': '&',
|
||||||
|
'<': '<',
|
||||||
|
'>': '>',
|
||||||
|
'"': '"',
|
||||||
|
"'": ''',
|
||||||
|
};
|
||||||
|
|
||||||
|
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.
|
* 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
|
* Walks up from each blocked img to find the nearest table cell or wrapper div
|
||||||
|
|||||||
Reference in New Issue
Block a user