diff --git a/server/src/emails/album-invite.email.tsx b/server/src/emails/album-invite.email.tsx index 49adb25e5..e82e2ec79 100644 --- a/server/src/emails/album-invite.email.tsx +++ b/server/src/emails/album-invite.email.tsx @@ -3,7 +3,7 @@ import * as React from 'react'; import { ImmichButton } from 'src/emails/components/button.component.js'; import ImmichLayout from 'src/emails/components/immich.layout.js'; import { AlbumInviteEmailProps } from 'src/repositories/email.repository.js'; -import { replaceTemplateTags } from 'src/utils/replace-template-tags.js'; +import { replaceTemplateTags, templatePreview } from 'src/utils/replace-template-tags.js'; export const AlbumInviteEmail = ({ baseUrl, @@ -37,7 +37,9 @@ export const AlbumInviteEmail = ({ ); return ( - + {customTemplate && (
diff --git a/server/src/emails/album-update.email.tsx b/server/src/emails/album-update.email.tsx index 443e54228..fea374149 100644 --- a/server/src/emails/album-update.email.tsx +++ b/server/src/emails/album-update.email.tsx @@ -3,7 +3,7 @@ import * as React from 'react'; import { ImmichButton } from 'src/emails/components/button.component.js'; import ImmichLayout from 'src/emails/components/immich.layout.js'; import { AlbumUpdateEmailProps } from 'src/repositories/email.repository.js'; -import { replaceTemplateTags } from 'src/utils/replace-template-tags.js'; +import { replaceTemplateTags, templatePreview } from 'src/utils/replace-template-tags.js'; export const AlbumUpdateEmail = ({ baseUrl, @@ -36,7 +36,13 @@ export const AlbumUpdateEmail = ({ ); return ( - + {customTemplate && (
diff --git a/server/src/emails/welcome.email.tsx b/server/src/emails/welcome.email.tsx index 2de52b0c8..ab026fd39 100644 --- a/server/src/emails/welcome.email.tsx +++ b/server/src/emails/welcome.email.tsx @@ -3,7 +3,7 @@ import * as React from 'react'; import { ImmichButton } from 'src/emails/components/button.component.js'; import ImmichLayout from 'src/emails/components/immich.layout.js'; import { WelcomeEmailProps } from 'src/repositories/email.repository.js'; -import { replaceTemplateTags } from 'src/utils/replace-template-tags.js'; +import { replaceTemplateTags, templatePreview } from 'src/utils/replace-template-tags.js'; export const WelcomeEmail = ({ baseUrl, displayName, username, password, customTemplate }: WelcomeEmailProps) => { const usableTemplateVariables = { @@ -37,7 +37,11 @@ export const WelcomeEmail = ({ baseUrl, displayName, username, password, customT return ( {customTemplate && ( diff --git a/server/src/repositories/email.repository.spec.ts b/server/src/repositories/email.repository.spec.ts index e9d7e4dee..fe54042e0 100644 --- a/server/src/repositories/email.repository.spec.ts +++ b/server/src/repositories/email.repository.spec.ts @@ -56,6 +56,28 @@ describe(EmailRepository.name, () => { expect(result.text).toContain('Vacation'); }); + it('should escape user controlled values in a custom template', async () => { + const request: EmailRenderRequest = { + template: EmailTemplate.ALBUM_INVITE, + data: { + albumName: '

Reset your password

', + albumId: '123', + senderName: '', + recipientName: "Tom & Jerry O'Brien", + baseUrl: 'http://localhost', + }, + customTemplate: '

Hey {recipientName}, {senderName} added you to {albumName}

', + }; + + const result = await sut.renderEmail(request); + + expect(result.html).toContain('<script>alert(1)</script>'); + expect(result.html).toContain('<a href="https://example.com">'); + expect(result.html).not.toContain(''); + expect(result.html).not.toContain(''); + expect(result.html).toContain('

Hey Tom & Jerry O'Brien'); + }); + it('should render the email correctly for ALBUM_UPDATE template', async () => { const request: EmailRenderRequest = { template: EmailTemplate.ALBUM_UPDATE, diff --git a/server/src/utils/replace-template-tags.spec.ts b/server/src/utils/replace-template-tags.spec.ts new file mode 100644 index 000000000..b3c800ada --- /dev/null +++ b/server/src/utils/replace-template-tags.spec.ts @@ -0,0 +1,40 @@ +import { replaceTemplateTags, templatePreview } from 'src/utils/replace-template-tags.js'; + +describe(replaceTemplateTags.name, () => { + it('should replace tags', () => { + expect(replaceTemplateTags('Hello {name}, welcome to {instance}', { name: 'Alan', instance: 'Immich' })).toBe( + 'Hello Alan, welcome to Immich', + ); + }); + + it('should leave unknown tags untouched', () => { + expect(replaceTemplateTags('Hello {name} {unknown}', { name: 'Alan' })).toBe('Hello Alan {unknown}'); + }); + + it('should escape html in values', () => { + expect( + replaceTemplateTags('

{name} shared {album}

', { + name: '', + album: '

x

', + }), + ).toBe( + '

<img src=x onerror="alert(1)"> shared </p><a href="https://example.com">x</a><p>

', + ); + }); + + it('should not escape html when disabled', () => { + expect(replaceTemplateTags('{name}', { name: 'Alan' }, { escapeHtml: false })).toBe('Alan'); + }); +}); + +describe(templatePreview.name, () => { + it('should produce plain text with unescaped values', () => { + expect(templatePreview('

Hey {name}!

', { name: "Tom & Jerry O'Brien" })).toBe( + "Hey Tom & Jerry O'Brien!", + ); + }); + + it('should strip tags that came from values', () => { + expect(templatePreview('

{name}

', { name: 'click' })).toBe('click'); + }); +}); diff --git a/server/src/utils/replace-template-tags.ts b/server/src/utils/replace-template-tags.ts index 70333d7df..255a7f155 100644 --- a/server/src/utils/replace-template-tags.ts +++ b/server/src/utils/replace-template-tags.ts @@ -1,5 +1,25 @@ -export const replaceTemplateTags = (template: string, variables: Record) => { +import { escape } from 'lodash-es'; + +export const replaceTemplateTags = ( + template: string, + variables: Record, + { escapeHtml = true } = {}, +) => { return template.replaceAll(/{(.*?)}/g, (_, key) => { - return variables[key] || `{${key}}`; + const value = variables[key]; + if (!value) { + return `{${key}}`; + } + return escapeHtml ? escape(value) : value; }); }; + +export const templatePreview = (template: string, variables: Record) => { + let text = replaceTemplateTags(template, variables, { escapeHtml: false }); + let previous: string; + do { + previous = text; + text = text.replaceAll(/<[^>]*>/g, ''); + } while (text !== previous); + return text; +};