fix: keep angle brackets and ampersands in plain-text templates and placeholder values

This commit is contained in:
Linus Rath
2026-11-02 22:52:03 +01:00
parent 86781b8a43
commit 0d2fdc44f4
4 changed files with 51 additions and 17 deletions
+1 -1
View File
@@ -2165,7 +2165,7 @@ export function EmailComposer({
? substitutePlaceholders(template.subject, filledValues)
: template.subject;
const filledBody = Object.keys(filledValues).length > 0
? substitutePlaceholders(template.body, filledValues)
? substitutePlaceholders(template.body, filledValues, { html: !!template.isHTML })
: template.body;
// Match the template to the draft's format: an HTML template goes into a
@@ -42,8 +42,8 @@ export function PlaceholderFillModal({
const dirty = placeholders.some((p) => values[p] !== initialValues[p]);
const preview = useMemo(() => {
return substitutePlaceholders(template.body, values);
}, [template.body, values]);
return substitutePlaceholders(template.body, values, { html: !!template.isHTML });
}, [template.body, template.isHTML, values]);
const requestClose = async () => {
if (await confirmDiscard(dirty)) onClose();
+36 -8
View File
@@ -10,6 +10,7 @@ import {
filterTemplates,
exportTemplates,
importTemplates,
parseSyncedTemplates,
spliceTemplateAboveSignature,
composeBodyHasUserContent,
embedTemplateImages,
@@ -79,9 +80,18 @@ describe('substitutePlaceholders', () => {
expect(substitutePlaceholders('{{known}} {{unknown}}', { known: 'yes' })).toBe('yes {{unknown}}');
});
it('sanitizes XSS in values', () => {
const result = substitutePlaceholders('{{name}}', { name: '<script>alert(1)</script>' });
expect(result).not.toContain('<script>');
it('escapes values going into an HTML template', () => {
const result = substitutePlaceholders('<p>{{name}}</p>', { name: '<script>alert(1)</script>' }, { html: true });
expect(result).toBe('<p>&lt;script&gt;alert(1)&lt;/script&gt;</p>');
});
// Values are text: an address in angle brackets or an ampersand is kept.
it('keeps values as typed in a plain template', () => {
expect(substitutePlaceholders('To {{name}}', { name: 'Smith & Co <j@x.com>' })).toBe('To Smith & Co <j@x.com>');
});
it('keeps the address in an HTML template visible', () => {
expect(substitutePlaceholders('<p>{{name}}</p>', { name: 'Smith <j@x.com>' }, { html: true })).toBe('<p>Smith &lt;j@x.com&gt;</p>');
});
it('handles empty values', () => {
@@ -284,15 +294,33 @@ describe('importTemplates', () => {
expect(result.errors).toContain('missing_template_name');
});
it('sanitizes imported values against XSS', () => {
// Name, subject, category and a plain body are text, shown as text: markup
// in them is harmless and is kept as typed, not cut out or entity-encoded.
it('keeps angle brackets and ampersands in plain-text fields', () => {
const json = JSON.stringify({
type: 'webmail-templates',
version: 1,
templates: [{ name: '<img onerror=alert(1) src=x>', subject: '<script>alert(1)</script>' }],
templates: [{
name: 'Sales <EU>',
subject: 'Re: {{recipient_name}} <{{recipient_email}}>',
category: 'R&D',
body: 'Please reply to <sales@example.com> if a<b & c',
isHTML: false,
}],
});
const result = importTemplates(json);
expect(result.templates[0].name).not.toContain('onerror');
expect(result.templates[0].subject).not.toContain('<script>');
const [tpl] = importTemplates(json).templates;
expect(tpl.name).toBe('Sales <EU>');
expect(tpl.subject).toBe('Re: {{recipient_name}} <{{recipient_email}}>');
expect(tpl.category).toBe('R&D');
expect(tpl.body).toBe('Please reply to <sales@example.com> if a<b & c');
});
it('keeps plain-text fields of synced templates as typed', () => {
const [tpl] = parseSyncedTemplates([
{ id: 't1', name: 'Sales', subject: 'a <= b', body: 'Write to <sales@example.com> & us', isHTML: false },
])!;
expect(tpl.subject).toBe('a <= b');
expect(tpl.body).toBe('Write to <sales@example.com> & us');
});
it('handles missing templates array', () => {
+12 -6
View File
@@ -1,12 +1,10 @@
import DOMPurify from 'dompurify';
import { sanitizeEmailHtml } from './email-sanitization';
import { escapeHtml, sanitizeEmailHtml } from './email-sanitization';
import type { EmailTemplate } from './template-types';
import { BUILT_IN_PLACEHOLDERS } from './template-types';
import { generateUUID } from './utils';
const PLACEHOLDER_REGEX = /\{\{(\w+)\}\}/g;
const MAX_TEMPLATE_NAME_LENGTH = 200;
const STRIP_HTML_CONFIG = { ALLOWED_TAGS: [] as string[], ALLOWED_ATTR: [] as string[] };
export function extractPlaceholders(text: string): string[] {
const matches = new Set<string>();
@@ -18,13 +16,18 @@ export function extractPlaceholders(text: string): string[] {
return Array.from(matches);
}
/**
* Fill in {{placeholders}}. Values are text: escaped for an HTML template,
* kept as typed for a plain one, so "Smith <j@x.com>" survives either way.
*/
export function substitutePlaceholders(
text: string,
values: Record<string, string>
values: Record<string, string>,
options: { html?: boolean } = {},
): string {
return text.replace(PLACEHOLDER_REGEX, (full, name) => {
if (values[name] === undefined) return full;
return DOMPurify.sanitize(values[name], STRIP_HTML_CONFIG);
return options.html ? escapeHtml(values[name]) : values[name];
});
}
@@ -189,8 +192,11 @@ export async function embedTemplateImages(
return changed ? doc.body.innerHTML : html;
}
// Names, subjects, categories and plain bodies are text, shown as text and
// escaped wherever they go into HTML. Running them through an HTML sanitizer
// cut "<sales@example.com>" out and turned "&" into "&amp;".
function sanitizeText(value: unknown): string {
return DOMPurify.sanitize(String(value || ''), STRIP_HTML_CONFIG);
return String(value || '');
}
/**