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
+13 -8
View File
@@ -8,6 +8,7 @@
import { formatDateTime } from "@/lib/utils";
import { emailHooks } from "@/lib/plugin-hooks";
import { escapeHtml } from "@/lib/email-sanitization";
import type { QuoteHeader, QuoteHeaderContext } from "@/lib/plugin-types";
// 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 fromStr = from ? `${from.name || from.email}` : unknownLabel;
// Forward header "From:" shows the full sender incl. address ("Name
// <email>"), like every mail client. The reply line keeps the bare name
// (reads more naturally in "On … wrote:").
// Both the forward "From:" line and the reply "On … wrote:" line show the
// full sender incl. address ("Name <email>"), like Gmail/Outlook (#482).
const fromStrFull = from
? (from.name && from.email && from.name !== from.email
? `${from.name} <${from.email}>`
@@ -75,13 +74,19 @@ function defaultHeader(args: BuildArgs): QuoteHeader {
if (mode === "forward") {
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 };
}
const replyLine = labels.formatReplyLine({ date, from: fromStr });
const text = `${replyLine}\n`;
const html = `<div>${replyLine}<br></div>`;
const text = `${labels.formatReplyLine({ date, from: fromStrFull })}\n`;
// Escape the interpolated sender/date for the HTML reply line: the sender is
// 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 };
}