From f05f70a9e5076771c33a1c29b118f3fba4727c37 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Sat, 18 Apr 2026 00:35:21 +0200
Subject: [PATCH] fix: prevent XSS via quote injection in plain-text email
linkifier
---
app/globals.css | 6 ++-
components/email/email-viewer.tsx | 33 ++----------
components/email/thread-conversation-view.tsx | 9 +---
lib/__tests__/email-sanitization.test.ts | 52 +++++++++++++++++++
lib/email-sanitization.ts | 34 ++++++++++++
5 files changed, 98 insertions(+), 36 deletions(-)
diff --git a/app/globals.css b/app/globals.css
index 65e1919e..0fb85bb6 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -209,10 +209,14 @@ body {
}
.email-content-text a {
- color: var(--color-primary);
+ color: #2563eb;
text-decoration: underline;
}
+.dark .email-content-text a {
+ color: #60a5fa;
+}
+
.email-content {
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx
index 426be247..50198d82 100644
--- a/components/email/email-viewer.tsx
+++ b/components/email/email-viewer.tsx
@@ -4,7 +4,7 @@ import { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback } fr
import ReactDOM from "react-dom";
import DOMPurify from "dompurify";
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 { Button } from "@/components/ui/button";
import { Avatar } from "@/components/ui/avatar";
@@ -2397,16 +2397,8 @@ export function EmailViewer({
if (email.textBody?.[0]?.partId && email.bodyValues[email.textBody[0].partId]) {
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(/(https?:\/\/[^\s<]+)/g, '$1');
-
return {
- html: htmlFromText,
+ html: plainTextToSafeHtml(textContent),
isHtml: false
};
}
@@ -2444,12 +2436,7 @@ export function EmailViewer({
return { html: cleanHtml, isHtml: true };
}
if (smimeDecryptedText) {
- const htmlFromText = smimeDecryptedText
- .replace(/&/g, '&')
- .replace(//g, '>')
- .replace(/(https?:\/\/[^\s<]+)/g, '$1');
- return { html: htmlFromText, isHtml: false };
+ return { html: plainTextToSafeHtml(smimeDecryptedText), isHtml: false };
}
// TNEF (winmail.dat) extracted content
if (tnefHtml) {
@@ -2457,12 +2444,7 @@ export function EmailViewer({
return { html: cleanHtml, isHtml: true };
}
if (tnefText) {
- const htmlFromText = tnefText
- .replace(/&/g, '&')
- .replace(//g, '>')
- .replace(/(https?:\/\/[^\s<]+)/g, '$1');
- return { html: htmlFromText, isHtml: false };
+ return { html: plainTextToSafeHtml(tnefText), isHtml: false };
}
// Embedded message/rfc822 unwrapped content
if (embeddedEmailHtml) {
@@ -2470,12 +2452,7 @@ export function EmailViewer({
return { html: cleanHtml, isHtml: true };
}
if (embeddedEmailText) {
- const htmlFromText = embeddedEmailText
- .replace(/&/g, '&')
- .replace(//g, '>')
- .replace(/(https?:\/\/[^\s<]+)/g, '$1');
- return { html: htmlFromText, isHtml: false };
+ return { html: plainTextToSafeHtml(embeddedEmailText), isHtml: false };
}
return emailContent;
}, [cidBlobUrls, emailContent, smimeDecryptedHtml, smimeDecryptedText, tnefHtml, tnefText, embeddedEmailHtml, embeddedEmailText]);
diff --git a/components/email/thread-conversation-view.tsx b/components/email/thread-conversation-view.tsx
index 86fa0ce2..9519a600 100644
--- a/components/email/thread-conversation-view.tsx
+++ b/components/email/thread-conversation-view.tsx
@@ -3,7 +3,7 @@
import { useState, useEffect, useMemo } from "react";
import DOMPurify from "dompurify";
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 { transformInlineStyles, transformColorForDarkMode, transformBgColorForDarkMode } from "@/lib/color-transform";
import { useThemeStore } from "@/stores/theme-store";
@@ -419,12 +419,7 @@ function EmailCard({
// Plain text fallback
if (email.textBody?.[0]?.partId && email.bodyValues[email.textBody[0].partId]) {
const text = email.bodyValues[email.textBody[0].partId].value;
- const htmlEscaped = text
- .replace(/&/g, '&')
- .replace(//g, '>')
- .replace(/(https?:\/\/[^\s<]+)/g, '$1');
- return { html: htmlEscaped, isHtml: false };
+ return { html: plainTextToSafeHtml(text, 'text-primary hover:underline'), isHtml: false };
}
}
diff --git a/lib/__tests__/email-sanitization.test.ts b/lib/__tests__/email-sanitization.test.ts
index 9c02379b..f36c5b50 100644
--- a/lib/__tests__/email-sanitization.test.ts
+++ b/lib/__tests__/email-sanitization.test.ts
@@ -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(' & "q" \'q\'');
+ expect(result).not.toContain('