From 8d813391f3606641e2715a98f6f756ffbadfa515 Mon Sep 17 00:00:00 2001 From: ykargeee Date: Sun, 28 Jun 2026 12:58:19 +0100 Subject: [PATCH 1/7] Add XSS sanitization to email templates --- .../templates/template-management.service.ts | 38 +++++++++++++++++-- src/modules/email-template.service.spec.ts | 14 +++++++ src/modules/email-template.service.ts | 37 ++++++++++++++++-- .../notification-template.service.spec.ts | 17 +++++++++ .../notification-template.service.ts | 34 ++++++++++++++++- 5 files changed, 131 insertions(+), 9 deletions(-) diff --git a/src/email-marketing/templates/template-management.service.ts b/src/email-marketing/templates/template-management.service.ts index 044a3e84..335e31e1 100644 --- a/src/email-marketing/templates/template-management.service.ts +++ b/src/email-marketing/templates/template-management.service.ts @@ -3,6 +3,7 @@ import { ResourceNotFoundException } from '../../common/exceptions/app.exception import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import * as Handlebars from 'handlebars'; +import sanitizeHtml from 'sanitize-html'; import { EmailTemplate } from '../entities/email-template.entity'; import { CreateTemplateDto } from '../dto/create-template.dto'; import { UpdateTemplateDto } from '../dto/update-template.dto'; @@ -96,6 +97,34 @@ export class TemplateManagementService { }); return this.templateRepository.save(duplicate); } + private sanitizeContext(context: Record): Record { + const sanitized: Record = {}; + const sanitizeOptions: any = { + allowedTags: [], // Disallow all HTML tags for most user input + allowedAttributes: {}, + }; + + for (const [key, value] of Object.entries(context)) { + if (typeof value === 'string') { + // URLs are an exception - we need to allow them to work properly + if (key.includes('url') || key.includes('link') || key.includes('href')) { + sanitized[key] = sanitizeHtml(value, { + allowedTags: [], + allowedAttributes: {}, + allowedSchemes: ['http', 'https', 'mailto'], + }); + } else { + sanitized[key] = sanitizeHtml(value, sanitizeOptions); + } + } else if (value && typeof value === 'object') { + sanitized[key] = this.sanitizeContext(value as Record); + } else { + sanitized[key] = value; + } + } + return sanitized; + } + /** * Render a template with variables */ @@ -108,13 +137,16 @@ export class TemplateManagementService { subject: string; }> { const template = await this.findOne(templateId); + const sanitizedVariables = this.sanitizeContext(variables); const htmlTemplate = Handlebars.compile(template.htmlContent); const subjectTemplate = Handlebars.compile(template.subject); const textTemplate = template.textContent ? Handlebars.compile(template.textContent) : null; return { - html: htmlTemplate(variables), - text: textTemplate ? textTemplate(variables) : this.stripHtml(htmlTemplate(variables)), - subject: subjectTemplate(variables), + html: htmlTemplate(sanitizedVariables), + text: textTemplate + ? textTemplate(sanitizedVariables) + : this.stripHtml(htmlTemplate(sanitizedVariables)), + subject: subjectTemplate(sanitizedVariables), }; } /** diff --git a/src/modules/email-template.service.spec.ts b/src/modules/email-template.service.spec.ts index be17a67e..6da3260c 100644 --- a/src/modules/email-template.service.spec.ts +++ b/src/modules/email-template.service.spec.ts @@ -44,4 +44,18 @@ describe('EmailTemplateService', () => { expect(result.subject).not.toContain('{{coupon}}'); }); + + it('sanitizes XSS payloads in preview variables', async () => { + const result = await service.preview('template-id', { + firstName: '', + coupon: '', + }); + + // Verify malicious scripts are not present in raw form + expect(result.body).not.toContain('', + courseName: '', + }); + // Verify that the malicious scripts are escaped/sanitized + expect(result.body).not.toContain('