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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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('<script>alert(1)</script>');
|
||||
expect(result.html).toContain('<a href="https://example.com">');
|
||||
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 & Jerry O'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><img src=x onerror="alert(1)"> shared </p><a href="https://example.com">x</a><p></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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user