diff --git a/BackendAcademy/src/notifications/dto/create-notification.dto.ts b/BackendAcademy/src/notifications/dto/create-notification.dto.ts index 5a602614d..d8588c387 100644 --- a/BackendAcademy/src/notifications/dto/create-notification.dto.ts +++ b/BackendAcademy/src/notifications/dto/create-notification.dto.ts @@ -1,4 +1,4 @@ -import { IsString, IsIn } from 'class-validator'; +import { IsString, IsIn, IsOptional } from 'class-validator'; export class CreateNotificationDto { @IsString() @@ -12,4 +12,16 @@ export class CreateNotificationDto { @IsString() message: string; + + /** + * Optional deterministic deduplication key. + * + * When provided, the notification service will reject duplicate + * notifications that carry the same event key within the configured + * deduplication window. This prevents retries and scheduled jobs from + * sending duplicate reminders or alerts. + */ + @IsOptional() + @IsString() + eventKey?: string; } diff --git a/BackendAcademy/src/notifications/email.service.ts b/BackendAcademy/src/notifications/email.service.ts index eff25ae0a..4f3497b7a 100644 --- a/BackendAcademy/src/notifications/email.service.ts +++ b/BackendAcademy/src/notifications/email.service.ts @@ -34,6 +34,80 @@ export interface EmailTemplateFields { [key: string]: string | undefined; } +/** + * HTML entity map for escaping user-supplied values. + * Prevents XSS when interpolated into HTML templates. + */ +const HTML_ESCAPE_MAP: Record = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + '/': '/', +}; + +/** + * Escapes HTML special characters in a string to prevent XSS. + * Used when interpolating user-supplied values into HTML email templates. + * + * @param raw - The raw string value to escape + * @returns The escaped string safe for HTML insertion + */ +export function escapeHtml(raw: string): string { + return raw.replace(/[&<>"'/]/g, (ch) => HTML_ESCAPE_MAP[ch] || ch); +} + +/** + * Escapes characters that are significant in plain-text email contexts. + * Specifically handles leading dots (which can be confused with SMTP + * command prefixes) and backslash-n sequences. + * + * @param raw - The raw string value to escape + * @returns The escaped string safe for plain-text insertion + */ +export function escapePlainText(raw: string): string { + // Escape leading dots (SMTP dot-stuffing safety) + return raw + .split('\n') + .map((line) => (line.startsWith('.') ? `.${line}` : line)) + .join('\n'); +} + +/** + * Strips known-dangerous HTML constructs from a value before interpolation. + * This is a defence-in-depth measure; primary protection is escaping. + * + * Removes `')).toBe( + '<script>alert("xss")</script>', + ); + }); + }); + + describe('stripDangerousHtml', () => { + it('removes script tags', () => { + expect(stripDangerousHtml('Hello World')).toBe('Hello World'); + }); + + it('removes script tags with attributes', () => { + expect(stripDangerousHtml('A B')).toBe('A B'); + }); + + it('removes iframe tags', () => { + expect(stripDangerousHtml('X Y')).toBe('X Y'); + }); + + it('removes object tags', () => { + expect(stripDangerousHtml('X Y')).toBe('X Y'); + }); + + it('removes embed tags', () => { + expect(stripDangerousHtml('X Y')).toBe('X Y'); + }); + + it('strips javascript: URIs', () => { + expect(stripDangerousHtml('click javascript:alert(1)')).toBe('click alert(1)'); + }); + + it('handles case-insensitive script tags', () => { + expect(stripDangerousHtml('')).toBe(''); + }); + + it('preserves safe HTML', () => { + const input = 'Hello World'; + expect(stripDangerousHtml(input)).toBe(input); + }); + }); + + describe('sanitiseTemplateValue', () => { + it('strips and escapes dangerous content', () => { + const result = sanitiseTemplateValue('Hello & goodbye'); + expect(result).not.toContain('