fix: HTML-escape sender/subject in reply/forward quote header (#482)

The forward quote header renders "From: Name <email>", but the HTML variant
interpolated the sender string unescaped. In the rich-text composer the
"<email>" portion is parsed by the browser as a bogus HTML tag and dropped, so
the address silently disappears - the user sees only "From: Display Name". The
plain-text variant and the details panel escape correctly, which is why the
address shows there. This is the regression from #367, which added the
"<email>" into the HTML string without escaping it.

Fix: HTML-escape the user-controlled values (sender, subject, date) in every
HTML quote-header path - the production builder in lib/quote-header.ts and the
composer's inline fallback (both htmlBody and plain-body branches), for forward
and reply. The reply line keeps the bare display name by design (#367), but its
HTML form is now escaped too so a display name containing markup can't break
out. As a side benefit this closes an HTML-injection vector: a crafted subject
or display name was previously injected raw into the composer document.

Adds lib/__tests__/quote-header.test.ts covering: forward text keeps
"Name <email>"; forward HTML escapes the angle brackets (address survives) and
a markup subject/display name; reply stays bare-name and HTML-safe.
This commit is contained in:
Stefan Hildebrandt
2026-06-25 00:25:28 +02:00
committed by Linus Rath
parent 70aaf0aac1
commit d863b1fd4b
3 changed files with 116 additions and 18 deletions
+11 -10
View File
@@ -11,7 +11,7 @@ import { debug } from "@/lib/debug";
import { toast } from "@/stores/toast-store"; import { toast } from "@/stores/toast-store";
import { useContextMenu } from "@/hooks/use-context-menu"; import { useContextMenu } from "@/hooks/use-context-menu";
import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu"; import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu";
import { sanitizeSignatureHtml, sanitizeEmailHtml } from "@/lib/email-sanitization"; import { sanitizeSignatureHtml, sanitizeEmailHtml, escapeHtml } from "@/lib/email-sanitization";
import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix"; import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix";
import { isFilePreviewable } from "@/lib/file-preview"; import { isFilePreviewable } from "@/lib/file-preview";
import { buildQuotedHtmlBlock, serializeEditorContent } from "@/components/email/quoted-html"; import { buildQuotedHtmlBlock, serializeEditorContent } from "@/components/email/quoted-html";
@@ -347,9 +347,8 @@ export function EmailComposer({
const date = replyTo.receivedAt ? formatDateTime(replyTo.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' }) : ""; const date = replyTo.receivedAt ? formatDateTime(replyTo.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' }) : "";
const from = replyTo.from?.[0]; const from = replyTo.from?.[0];
const fromStr = from ? `${from.name || from.email}` : tCommon('unknown'); // Forward "From:" and the reply "On … wrote:" line both show the full
// Forward "From:" shows the full sender incl. address; reply line keeps // sender incl. address ("Name <email>"), like Gmail/Outlook (#482).
// the bare name (reads naturally in the localized "On … wrote:" line).
const fromStrFull = from const fromStrFull = from
? (from.name && from.email && from.name !== from.email ? (from.name && from.email && from.name !== from.email
? `${from.name} <${from.email}>` ? `${from.name} <${from.email}>`
@@ -377,7 +376,7 @@ export function EmailComposer({
if (mode === 'forward') { if (mode === 'forward') {
return `${prefix}${signatureBlock}\n\n${tQuote('forwarded_separator')}\n${tQuote('from_label')}: ${fromStrFull}\n${tQuote('date_label')}: ${date}\n${tQuote('subject_label')}: ${replyTo.subject || ''}\n\n${originalText}`; return `${prefix}${signatureBlock}\n\n${tQuote('forwarded_separator')}\n${tQuote('from_label')}: ${fromStrFull}\n${tQuote('date_label')}: ${date}\n${tQuote('subject_label')}: ${replyTo.subject || ''}\n\n${originalText}`;
} else if (mode === 'reply' || mode === 'replyAll') { } else if (mode === 'reply' || mode === 'replyAll') {
return `${prefix}${signatureBlock}\n\n${tQuote('reply_line', { date, from: fromStr })}\n${quotedText}`; return `${prefix}${signatureBlock}\n\n${tQuote('reply_line', { date, from: fromStrFull })}\n${quotedText}`;
} }
return prefix; return prefix;
} }
@@ -399,7 +398,7 @@ export function EmailComposer({
const date = replyTo.receivedAt ? formatDateTime(replyTo.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' }) : ""; const date = replyTo.receivedAt ? formatDateTime(replyTo.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' }) : "";
const from = replyTo.from?.[0]; const from = replyTo.from?.[0];
const fromStr = from ? `${from.name || from.email}` : tCommon('unknown'); // Forward and reply quote lines both show the full "Name <email>" sender (#482).
const fromStrFull = from const fromStrFull = from
? (from.name && from.email && from.name !== from.email ? (from.name && from.email && from.name !== from.email
? `${from.name} <${from.email}>` ? `${from.name} <${from.email}>`
@@ -434,9 +433,11 @@ export function EmailComposer({
// Build quoted content as HTML // Build quoted content as HTML
if (replyTo.htmlBody && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) { if (replyTo.htmlBody && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) {
// HTML-escape user-controlled values: an unescaped sender "Name <email>"
// has its "<email>" eaten as a bogus HTML tag by the rich-text editor (#482).
const quoteHeader = mode === 'forward' const quoteHeader = mode === 'forward'
? `${tQuote('forwarded_separator')}<br>${tQuote('from_label')}: ${fromStrFull}<br>${tQuote('date_label')}: ${date}<br>${tQuote('subject_label')}: ${replyTo.subject || ''}<br><br>` ? `${tQuote('forwarded_separator')}<br>${tQuote('from_label')}: ${escapeHtml(fromStrFull)}<br>${tQuote('date_label')}: ${escapeHtml(date)}<br>${tQuote('subject_label')}: ${escapeHtml(replyTo.subject || '')}<br><br>`
: `${tQuote('reply_line', { date, from: fromStr })}<br>`; : `${tQuote('reply_line', { date: escapeHtml(date), from: escapeHtml(fromStrFull) })}<br>`;
// Embed the original as a QuotedHtml island (verbatim, schema-free) so // Embed the original as a QuotedHtml island (verbatim, schema-free) so
// its layout survives the editor round-trip. Sanitize first to strip // its layout survives the editor round-trip. Sanitize first to strip
// scripts/styles/head; cid rewrite afterwards so data-cid markers // scripts/styles/head; cid rewrite afterwards so data-cid markers
@@ -450,9 +451,9 @@ export function EmailComposer({
if (replyTo.body) { if (replyTo.body) {
const escapedOriginal = replyTo.body.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>'); const escapedOriginal = replyTo.body.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>');
if (mode === 'forward') { if (mode === 'forward') {
return `${prefix}${signatureBlock}<br><br>${tQuote('forwarded_separator')}<br>${tQuote('from_label')}: ${fromStrFull}<br>${tQuote('date_label')}: ${date}<br>${tQuote('subject_label')}: ${replyTo.subject || ''}<br><br>${escapedOriginal}`; return `${prefix}${signatureBlock}<br><br>${tQuote('forwarded_separator')}<br>${tQuote('from_label')}: ${escapeHtml(fromStrFull)}<br>${tQuote('date_label')}: ${escapeHtml(date)}<br>${tQuote('subject_label')}: ${escapeHtml(replyTo.subject || '')}<br><br>${escapedOriginal}`;
} else if (mode === 'reply' || mode === 'replyAll') { } else if (mode === 'reply' || mode === 'replyAll') {
return `${prefix}${signatureBlock}<br><br>${tQuote('reply_line', { date, from: fromStr })}<br><blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${escapedOriginal}</blockquote>`; return `${prefix}${signatureBlock}<br><br>${tQuote('reply_line', { date: escapeHtml(date), from: escapeHtml(fromStrFull) })}<br><blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${escapedOriginal}</blockquote>`;
} }
} }
return prefix; return prefix;
+92
View File
@@ -0,0 +1,92 @@
import { describe, it, expect } from 'vitest';
import { buildQuoteHeader } from '@/lib/quote-header';
const base = {
newTo: [] as string[],
newCc: [] as string[],
locale: 'en',
timeFormat: '24h' as const,
unknownLabel: 'Unknown',
};
const sender = { name: 'Display Name', email: 'user@domain.tld' };
describe('buildQuoteHeader (#482 — sender address survives HTML rendering)', () => {
it('forward TEXT keeps the full "Name <email>" sender', async () => {
const h = await buildQuoteHeader({
mode: 'forward',
email: { from: [sender], subject: 'Hello', receivedAt: '2026-01-01T10:00:00Z' },
...base,
});
expect(h.text).toContain('From: Display Name <user@domain.tld>');
});
it('forward HTML escapes the angle brackets so the address is not eaten as a tag', async () => {
const h = await buildQuoteHeader({
mode: 'forward',
email: { from: [sender], subject: 'Hello', receivedAt: '2026-01-01T10:00:00Z' },
...base,
});
// The regression: a raw "<user@domain.tld>" is parsed as an HTML tag by the
// rich-text composer and dropped, leaving only "From: Display Name".
expect(h.html).toContain('Display Name &lt;user@domain.tld&gt;');
expect(h.html).not.toContain('<user@domain.tld>');
});
it('forward HTML escapes a subject containing markup (injection hardening)', async () => {
const h = await buildQuoteHeader({
mode: 'forward',
email: { from: [sender], subject: 'Hi <b>x</b>', receivedAt: '2026-01-01T10:00:00Z' },
...base,
});
expect(h.html).toContain('Hi &lt;b&gt;x&lt;/b&gt;');
expect(h.html).not.toContain('<b>x</b>');
});
it('forward HTML escapes a malicious display name', async () => {
const h = await buildQuoteHeader({
mode: 'forward',
email: {
from: [{ name: '<img src=x onerror=alert(1)>', email: 'evil@x.tld' }],
subject: 'Hello',
receivedAt: '2026-01-01T10:00:00Z',
},
...base,
});
expect(h.html).not.toContain('<img src=x');
expect(h.html).toContain('&lt;img src=x');
});
it('reply line includes the full "Name <email>" sender, escaped in HTML', async () => {
const h = await buildQuoteHeader({
mode: 'reply',
email: { from: [sender], subject: 'Hello', receivedAt: '2026-01-01T10:00:00Z' },
...base,
});
// TEXT keeps the real angle brackets ("On <date>, Display Name <user@domain.tld> wrote:").
expect(h.text).toContain('Display Name <user@domain.tld> wrote:');
// HTML escapes them so the address survives the rich-text editor (#482).
expect(h.html).toContain('Display Name &lt;user@domain.tld&gt;');
expect(h.html).not.toContain('<user@domain.tld>');
});
it('reply line stays HTML-safe for a display name containing markup', async () => {
const evil = await buildQuoteHeader({
mode: 'reply',
email: { from: [{ name: '<b>x</b>', email: 'e@x.tld' }], subject: 'Hello', receivedAt: '2026-01-01T10:00:00Z' },
...base,
});
expect(evil.html).not.toContain('<b>x</b>');
expect(evil.html).toContain('&lt;b&gt;x&lt;/b&gt;');
});
it('reply line falls back to bare email when there is no display name', async () => {
const h = await buildQuoteHeader({
mode: 'reply',
email: { from: [{ email: 'noname@x.tld' }], subject: 'Hello', receivedAt: '2026-01-01T10:00:00Z' },
...base,
});
expect(h.text).toContain('noname@x.tld wrote:');
expect(h.text).not.toContain('<noname@x.tld>');
});
});
+13 -8
View File
@@ -8,6 +8,7 @@
import { formatDateTime } from "@/lib/utils"; import { formatDateTime } from "@/lib/utils";
import { emailHooks } from "@/lib/plugin-hooks"; import { emailHooks } from "@/lib/plugin-hooks";
import { escapeHtml } from "@/lib/email-sanitization";
import type { QuoteHeader, QuoteHeaderContext } from "@/lib/plugin-types"; import type { QuoteHeader, QuoteHeaderContext } from "@/lib/plugin-types";
// Localized label set the caller passes in. Labels live on the client where // Localized label set the caller passes in. Labels live on the client where
@@ -62,10 +63,8 @@ function defaultHeader(args: BuildArgs): QuoteHeader {
}) })
: ""; : "";
const from = email.from?.[0]; const from = email.from?.[0];
const fromStr = from ? `${from.name || from.email}` : unknownLabel; // Both the forward "From:" line and the reply "On … wrote:" line show the
// Forward header "From:" shows the full sender incl. address ("Name // full sender incl. address ("Name <email>"), like Gmail/Outlook (#482).
// <email>"), like every mail client. The reply line keeps the bare name
// (reads more naturally in "On … wrote:").
const fromStrFull = from const fromStrFull = from
? (from.name && from.email && from.name !== from.email ? (from.name && from.email && from.name !== from.email
? `${from.name} <${from.email}>` ? `${from.name} <${from.email}>`
@@ -75,13 +74,19 @@ function defaultHeader(args: BuildArgs): QuoteHeader {
if (mode === "forward") { if (mode === "forward") {
const text = `${labels.forwardedSeparator}\n${labels.fromLabel}: ${fromStrFull}\n${labels.dateLabel}: ${date}\n${labels.subjectLabel}: ${subject}\n`; const text = `${labels.forwardedSeparator}\n${labels.fromLabel}: ${fromStrFull}\n${labels.dateLabel}: ${date}\n${labels.subjectLabel}: ${subject}\n`;
const html = `<div>${labels.forwardedSeparator}<br>${labels.fromLabel}: ${fromStrFull}<br>${labels.dateLabel}: ${date}<br>${labels.subjectLabel}: ${subject}<br><br></div>`; // Escape the interpolated values for the HTML variant: the sender string is
// "Name <email>", and the unescaped "<email>" would be parsed as an HTML tag
// by the rich-text composer and silently dropped (#482). Subject/name are
// likewise user-controlled. Label/separator strings are trusted i18n text.
const html = `<div>${labels.forwardedSeparator}<br>${labels.fromLabel}: ${escapeHtml(fromStrFull)}<br>${labels.dateLabel}: ${escapeHtml(date)}<br>${labels.subjectLabel}: ${escapeHtml(subject)}<br><br></div>`;
return { html, text, wrapInBlockquote: false }; return { html, text, wrapInBlockquote: false };
} }
const replyLine = labels.formatReplyLine({ date, from: fromStr }); const text = `${labels.formatReplyLine({ date, from: fromStrFull })}\n`;
const text = `${replyLine}\n`; // Escape the interpolated sender/date for the HTML reply line: the sender is
const html = `<div>${replyLine}<br></div>`; // now "Name <email>", and the unescaped "<email>" would be parsed as an HTML
// tag by the rich-text composer and dropped (#482). Label template is trusted.
const html = `<div>${labels.formatReplyLine({ date: escapeHtml(date), from: escapeHtml(fromStrFull) })}<br></div>`;
return { html, text, wrapInBlockquote: true }; return { html, text, wrapInBlockquote: true };
} }