FRE-4520: Fix security vulnerabilities in notification template system

- Fix HTML injection vulnerability with proper entity encoding
- Fix rate limit cleanup bug (count vs timestamp confusion)
- Add URL validation to prevent open redirect attacks
- Add expiration to in-memory deduplication entries
- Use Zod schema for config validation
- Add email format validation

All 29 tests passing. Ready for Code Reviewer final review.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-05-01 19:35:22 -04:00
parent 2a5c6f49a7
commit c490735ba2
5 changed files with 161 additions and 48 deletions

View File

@@ -21,6 +21,11 @@ export interface RateLimitResult {
resetInSeconds: number;
}
interface DeduplicationEntry {
externalIds: Set<string>;
expiresAt: number;
}
export class NotificationService {
private static instance: NotificationService;
private emailService: EmailService;
@@ -28,7 +33,7 @@ export class NotificationService {
private pushService: PushService;
private redisService: RedisService;
private config: ReturnType<typeof loadNotificationConfig>;
private pendingDeduplication = new Map<string, Set<string>>();
private pendingDeduplication = new Map<string, DeduplicationEntry>();
private preferenceCache = new Map<string, NotificationPreference>();
private constructor() {
@@ -64,9 +69,10 @@ export class NotificationService {
dedupKey: DeduplicationKey
): Promise<NotificationResult> {
const dedupId = `${dedupKey.userId}:${dedupKey.templateId}:${dedupKey.key}`;
const windowSet = this.pendingDeduplication.get(dedupId);
const windowSeconds = dedupKey.windowSeconds || this.config.redis.dedupWindowSeconds;
const entry = this.pendingDeduplication.get(dedupId);
if (windowSet && windowSet.size > 0) {
if (entry && Date.now() < entry.expiresAt && entry.externalIds.size > 0) {
return {
notificationId: `dedup-${Date.now()}`,
channel: notification.channel,
@@ -78,10 +84,11 @@ export class NotificationService {
const result = await this.send(notification);
if (result.status === 'sent') {
if (!windowSet) {
this.pendingDeduplication.set(dedupId, new Set());
}
this.pendingDeduplication.get(dedupId)!.add(result.externalId!);
const now = Date.now();
this.pendingDeduplication.set(dedupId, {
externalIds: new Set([result.externalId!]),
expiresAt: now + windowSeconds * 1000,
});
}
return result;