From c66634fcfd963e97d3488410f3ed472c4fd3721e Mon Sep 17 00:00:00 2001
From: bo0tzz
Date: Tue, 20 Oct 2026 15:33:33 +0200
Subject: [PATCH] fix(server): escape template variables in custom email
templates (#32563)
* fix(server): escape template variables in custom email templates
* fix(server): strip preview tags until stable
(cherry picked from commit 616839e8d43a9de21d0c9391847fba97ac41a759)
---
server/src/emails/album-invite.email.tsx | 6 ++-
server/src/emails/album-update.email.tsx | 10 ++++-
server/src/emails/welcome.email.tsx | 8 +++-
.../src/repositories/email.repository.spec.ts | 22 ++++++++++
.../src/utils/replace-template-tags.spec.ts | 40 +++++++++++++++++++
server/src/utils/replace-template-tags.ts | 24 ++++++++++-
6 files changed, 102 insertions(+), 8 deletions(-)
create mode 100644 server/src/utils/replace-template-tags.spec.ts
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;
+};