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)
This commit is contained in:
bo0tzz
2026-10-22 05:21:00 +03:00
committed by Codex
parent d92248c664
commit c66634fcfd
6 changed files with 102 additions and 8 deletions
+4 -2
View File
@@ -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 (
<ImmichLayout preview={customTemplate ? emailContent.toString() : 'You have been added to a shared album.'}>
<ImmichLayout
preview={customTemplate ? templatePreview(customTemplate, variables) : 'You have been added to a shared album.'}
>
{customTemplate && (
<Text className="m-0">
<div dangerouslySetInnerHTML={{ __html: emailContent }}></div>
+8 -2
View File
@@ -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 (
<ImmichLayout preview={customTemplate ? emailContent.toString() : 'New media has been added to a shared album.'}>
<ImmichLayout
preview={
customTemplate
? templatePreview(customTemplate, usableTemplateVariables)
: 'New media has been added to a shared album.'
}
>
{customTemplate && (
<Text className="m-0">
<div dangerouslySetInnerHTML={{ __html: emailContent }}></div>
+6 -2
View File
@@ -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 (
<ImmichLayout
preview={customTemplate ? emailContent.toString() : 'You have been invited to a new Immich instance.'}
preview={
customTemplate
? templatePreview(customTemplate, usableTemplateVariables)
: 'You have been invited to a new Immich instance.'
}
>
{customTemplate && (
<Text className="m-0">
@@ -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: '</p><a href="https://example.com">Reset your password</a><p>',
albumId: '123',
senderName: '<script>alert(1)</script>',
recipientName: "Tom & Jerry O'Brien",
baseUrl: 'http://localhost',
},
customTemplate: '<p>Hey {recipientName}, {senderName} added you to {albumName}</p>',
};
const result = await sut.renderEmail(request);
expect(result.html).toContain('&lt;script&gt;alert(1)&lt;/script&gt;');
expect(result.html).toContain('&lt;a href=&quot;https://example.com&quot;&gt;');
expect(result.html).not.toContain('<script>alert(1)</script>');
expect(result.html).not.toContain('<a href="https://example.com">');
expect(result.html).toContain('<p>Hey Tom &amp; Jerry O&#39;Brien');
});
it('should render the email correctly for ALBUM_UPDATE template', async () => {
const request: EmailRenderRequest = {
template: EmailTemplate.ALBUM_UPDATE,
@@ -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('<p>{name} shared {album}</p>', {
name: '<img src=x onerror="alert(1)">',
album: '</p><a href="https://example.com">x</a><p>',
}),
).toBe(
'<p>&lt;img src=x onerror=&quot;alert(1)&quot;&gt; shared &lt;/p&gt;&lt;a href=&quot;https://example.com&quot;&gt;x&lt;/a&gt;&lt;p&gt;</p>',
);
});
it('should not escape html when disabled', () => {
expect(replaceTemplateTags('{name}', { name: '<b>Alan</b>' }, { escapeHtml: false })).toBe('<b>Alan</b>');
});
});
describe(templatePreview.name, () => {
it('should produce plain text with unescaped values', () => {
expect(templatePreview('<p>Hey <strong>{name}</strong>!</p>', { name: "Tom & Jerry O'Brien" })).toBe(
"Hey Tom & Jerry O'Brien!",
);
});
it('should strip tags that came from values', () => {
expect(templatePreview('<p>{name}</p>', { name: '<a href="https://example.com">click</a>' })).toBe('click');
});
});
+22 -2
View File
@@ -1,5 +1,25 @@
export const replaceTemplateTags = (template: string, variables: Record<string, string | undefined>) => {
import { escape } from 'lodash-es';
export const replaceTemplateTags = (
template: string,
variables: Record<string, string | undefined>,
{ 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<string, string | undefined>) => {
let text = replaceTemplateTags(template, variables, { escapeHtml: false });
let previous: string;
do {
previous = text;
text = text.replaceAll(/<[^>]*>/g, '');
} while (text !== previous);
return text;
};