Files
ShieldAI/packages/shared-notifications/src/services/notification.service.ts
Michael Freno c490735ba2 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>
2026-05-01 19:35:22 -04:00

290 lines
8.2 KiB
TypeScript

import { EmailService } from './email.service';
import { SMSService } from './sms.service';
import { PushService } from './push.service';
import { TemplateService } from './template.service';
import { RedisService } from './redis.service';
import { loadNotificationConfig } from '../config/notification.config';
import type {
Notification,
NotificationChannel,
NotificationResult,
NotificationPreference,
DeduplicationKey
} from '../types/notification.types';
import type { TemplateResolutionOptions } from '../types/template.types';
export interface RateLimitResult {
allowed: boolean;
currentCount: number;
limit: number;
remaining: number;
resetInSeconds: number;
}
interface DeduplicationEntry {
externalIds: Set<string>;
expiresAt: number;
}
export class NotificationService {
private static instance: NotificationService;
private emailService: EmailService;
private smsService: SMSService;
private pushService: PushService;
private redisService: RedisService;
private config: ReturnType<typeof loadNotificationConfig>;
private pendingDeduplication = new Map<string, DeduplicationEntry>();
private preferenceCache = new Map<string, NotificationPreference>();
private constructor() {
this.emailService = EmailService.getInstance();
this.smsService = SMSService.getInstance();
this.pushService = PushService.getInstance();
this.config = loadNotificationConfig();
this.redisService = RedisService.getInstance({ url: this.config.redis.url });
}
static getInstance(): NotificationService {
if (!NotificationService.instance) {
NotificationService.instance = new NotificationService();
}
return NotificationService.instance;
}
async send(notification: Notification): Promise<NotificationResult> {
switch (notification.channel) {
case 'email':
return this.emailService.send(notification);
case 'sms':
return this.smsService.send(notification);
case 'push':
return this.pushService.send(notification);
default:
throw new Error(`Unknown notification channel: ${(notification as any).channel}`);
}
}
async sendWithDeduplication(
notification: Notification,
dedupKey: DeduplicationKey
): Promise<NotificationResult> {
const dedupId = `${dedupKey.userId}:${dedupKey.templateId}:${dedupKey.key}`;
const windowSeconds = dedupKey.windowSeconds || this.config.redis.dedupWindowSeconds;
const entry = this.pendingDeduplication.get(dedupId);
if (entry && Date.now() < entry.expiresAt && entry.externalIds.size > 0) {
return {
notificationId: `dedup-${Date.now()}`,
channel: notification.channel,
status: 'pending',
error: 'Duplicate notification within deduplication window',
};
}
const result = await this.send(notification);
if (result.status === 'sent') {
const now = Date.now();
this.pendingDeduplication.set(dedupId, {
externalIds: new Set([result.externalId!]),
expiresAt: now + windowSeconds * 1000,
});
}
return result;
}
async setPreference(
userId: string,
channel: NotificationPreference['channel'],
enabled: boolean,
categories?: string[]
): Promise<NotificationPreference> {
const preference: NotificationPreference = {
userId,
channel,
enabled,
categories: categories || [],
updatedAt: new Date(),
};
this.preferenceCache.set(`${userId}:${channel}`, preference);
return preference;
}
async getPreference(
userId: string,
channel: NotificationPreference['channel']
): Promise<NotificationPreference | null> {
return this.preferenceCache.get(`${userId}:${channel}`) || null;
}
async shouldSend(
userId: string,
channel: NotificationPreference['channel'],
category: string
): Promise<boolean> {
const preference = await this.getPreference(userId, channel);
if (!preference) {
return true;
}
if (!preference.enabled) {
return false;
}
if (preference.categories.length === 0) {
return true;
}
return preference.categories.includes(category);
}
async sendWithPreferences(
notification: Notification,
category: string
): Promise<NotificationResult | null> {
const userId = notification.channel === 'push'
? notification.userId
: `user-${Date.now()}`;
const shouldSend = await this.shouldSend(
userId,
notification.channel,
category
);
if (!shouldSend) {
return {
notificationId: `pref-${Date.now()}`,
channel: notification.channel,
status: 'pending',
error: 'Notification disabled for user preference',
};
}
return this.send(notification);
}
async sendWithTemplate(
recipient: string,
options: TemplateResolutionOptions & { channel?: NotificationChannel }
): Promise<NotificationResult> {
const channel = options.channel || 'email';
const templateService = TemplateService.getInstance();
const resolved = templateService.resolveTemplate({
templateId: options.templateId,
locale: options.locale,
variables: options.variables,
fallbackLocale: options.fallbackLocale,
});
if (!resolved) {
return {
notificationId: `${channel}-${Date.now()}`,
channel,
status: 'failed',
error: `Template not found: ${options.templateId}`,
};
}
if (resolved.channel !== channel) {
return {
notificationId: `${channel}-${Date.now()}`,
channel,
status: 'failed',
error: `Template ${options.templateId} is for channel '${resolved.channel}', not '${channel}'`,
};
}
switch (channel) {
case 'email':
return this.emailService.sendWithTemplate(recipient, options);
case 'sms':
return this.smsService.send({
channel: 'sms',
to: recipient,
body: resolved.body,
});
case 'push':
return this.pushService.send({
channel: 'push',
userId: recipient,
title: resolved.subject || '',
body: resolved.body,
});
default:
return {
notificationId: `${channel}-${Date.now()}`,
channel,
status: 'failed',
error: `Unknown channel: ${channel}`,
};
}
}
async checkRateLimit(
identifier: string,
channel: NotificationChannel,
customLimit?: number,
customWindowSeconds?: number
): Promise<RateLimitResult> {
const limit = customLimit || this.getLimitForChannel(channel);
const windowSeconds = customWindowSeconds || this.config.rateLimits.windowSeconds;
const key = `rl:${channel}:${identifier}`;
const currentCount = await this.redisService.increment(key, windowSeconds);
const ttl = await this.redisService.getTTL(key);
return {
allowed: currentCount <= limit,
currentCount,
limit,
remaining: Math.max(0, limit - currentCount),
resetInSeconds: Math.max(1, ttl),
};
}
async deduplicateNotification(
dedupKey: DeduplicationKey,
customWindowSeconds?: number
): Promise<boolean> {
const dedupId = `dedup:${dedupKey.userId}:${dedupKey.templateId}:${dedupKey.key}`;
const windowSeconds = customWindowSeconds || dedupKey.windowSeconds || this.config.redis.dedupWindowSeconds;
const wasSet = await this.redisService.setIfNotExists(dedupId, '1', windowSeconds);
return wasSet;
}
getRateLimitConfig(): {
emailPerMinute: number;
smsPerMinute: number;
pushPerMinute: number;
windowSeconds: number;
} {
return {
emailPerMinute: this.config.rateLimits.emailPerMinute,
smsPerMinute: this.config.rateLimits.smsPerMinute,
pushPerMinute: this.config.rateLimits.pushPerMinute,
windowSeconds: this.config.rateLimits.windowSeconds,
};
}
getTemplateService(): TemplateService {
return TemplateService.getInstance();
}
private getLimitForChannel(channel: NotificationChannel): number {
switch (channel) {
case 'email':
return this.config.rateLimits.emailPerMinute;
case 'sms':
return this.config.rateLimits.smsPerMinute;
case 'push':
return this.config.rateLimits.pushPerMinute;
}
}
}