diff --git a/components/templates/placeholder-fill-modal.tsx b/components/templates/placeholder-fill-modal.tsx index b274b1de1..e66ad426c 100644 --- a/components/templates/placeholder-fill-modal.tsx +++ b/components/templates/placeholder-fill-modal.tsx @@ -7,6 +7,7 @@ import { cn } from '@/lib/utils'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { substitutePlaceholders, isBuiltInPlaceholder } from '@/lib/template-utils'; +import { sanitizeEmailHtml } from '@/lib/email-sanitization'; import { useFocusTrap } from '@/hooks/use-focus-trap'; import type { EmailTemplate } from '@/lib/template-types'; @@ -94,7 +95,8 @@ export function PlaceholderFillModal({

{t('preview')}

- {template.isHTML ?
: preview} + {/* The body may come from an imported file or a synced blob; it is markup in the app document here. */} + {template.isHTML ?
: preview}
)} diff --git a/lib/__tests__/template-utils.test.ts b/lib/__tests__/template-utils.test.ts index 16404ade4..f33d01288 100644 --- a/lib/__tests__/template-utils.test.ts +++ b/lib/__tests__/template-utils.test.ts @@ -221,6 +221,20 @@ describe('importTemplates', () => { expect(result.templates[0].id).not.toBe('test-id'); }); + it('strips script and event handlers from an imported HTML body', () => { + // The body reaches the composer's placeholder preview as markup in the + // app document; it used to be kept exactly as the file had it. + const json = JSON.stringify({ + type: 'webmail-templates', + version: 1, + templates: [{ name: 'Evil', isHTML: true, body: '

Hi {{name}}

' }], + }); + const body = importTemplates(json).templates[0].body; + expect(body).toContain('Hi {{name}}'); + expect(body).not.toContain('onerror'); + expect(body).not.toContain(' { const result = importTemplates('not json'); expect(result.templates).toHaveLength(0); diff --git a/lib/template-utils.ts b/lib/template-utils.ts index f509c488e..b6a4bf538 100644 --- a/lib/template-utils.ts +++ b/lib/template-utils.ts @@ -1,4 +1,5 @@ import DOMPurify from 'dompurify'; +import { sanitizeEmailHtml } from './email-sanitization'; import type { EmailTemplate } from './template-types'; import { BUILT_IN_PLACEHOLDERS } from './template-types'; import { generateUUID } from './utils'; @@ -138,6 +139,16 @@ function sanitizeText(value: unknown): string { return DOMPurify.sanitize(String(value || ''), STRIP_HTML_CONFIG); } +/** + * An HTML template body from outside the app (a templates file, a synced + * settings blob written by another client) as the store may hold it. Bodies + * the plugin API creates are sanitised at creation; these were kept raw and + * reached the composer's placeholder preview as markup in the app document. + */ +function sanitizeTemplateHtml(value: unknown): string { + return sanitizeEmailHtml(String(value || '')); +} + interface ExportData { version: 1; type: 'webmail-templates'; @@ -199,7 +210,7 @@ export function parseSyncedTemplates(value: unknown): EmailTemplate[] | null { id: t.id, name: sanitizeText(t.name), subject: sanitizeText(t.subject), - body: t.isHTML ? String(t.body || '') : sanitizeText(t.body), + body: t.isHTML ? sanitizeTemplateHtml(t.body) : sanitizeText(t.body), isHTML: Boolean(t.isHTML), category: sanitizeText(t.category), defaultRecipients: recipients && typeof recipients === 'object' @@ -277,7 +288,7 @@ export function importTemplates(json: string): ImportResult { id: generateUUID(), name: sanitizeText(t.name), subject: sanitizeText(t.subject), - body: t.isHTML ? String(t.body || '') : sanitizeText(t.body), + body: t.isHTML ? sanitizeTemplateHtml(t.body) : sanitizeText(t.body), isHTML: Boolean(t.isHTML), category: sanitizeText(t.category), defaultRecipients: recipients && typeof recipients === 'object'