import {ProjectDisabledEmail, sendPlatformEmail} from '@plunk/email'; import React from 'react'; import signale from 'signale'; import {prisma} from '../database/prisma.js'; import {redis} from '../database/redis.js'; import {Keys} from './keys.js'; import {MembershipService} from './MembershipService.js'; import {NtfyService} from './NtfyService.js'; import {QueueService} from './QueueService.js'; import {AUTO_PROJECT_DISABLE, DASHBOARD_URI, LANDING_URI} from '../app/constants.js'; /** * Security thresholds for bounce and complaint rates * These limits protect AWS SES reputation and prevent account suspension */ const SECURITY_THRESHOLDS = { // Minimum emails required before enforcing rate-based limits (prevents false positives) MIN_EMAILS_FOR_ENFORCEMENT: 100, // Bounce rate thresholds (hard bounces only) BOUNCE_7DAY_WARNING: 5, BOUNCE_7DAY_CRITICAL: 10, BOUNCE_ALLTIME_WARNING: 4, BOUNCE_ALLTIME_CRITICAL: 8, // Complaint rate thresholds (spam reports) COMPLAINT_7DAY_WARNING: 0.075, COMPLAINT_7DAY_CRITICAL: 0.15, COMPLAINT_ALLTIME_WARNING: 0.03, COMPLAINT_ALLTIME_CRITICAL: 0.12, // Minimum absolute counts (prevents small sample size false positives) // Both percentage AND absolute count must be exceeded to trigger rate-based checks MIN_BOUNCES_FOR_CRITICAL: 10, MIN_BOUNCES_FOR_WARNING: 5, MIN_COMPLAINTS_FOR_CRITICAL: 5, MIN_COMPLAINTS_FOR_WARNING: 3, // === Absolute count ceilings === // These trigger regardless of rate — catches high-volume spammers who dilute their bounce rate // 24-hour absolute ceilings BOUNCE_24H_CEILING_WARNING: 50, BOUNCE_24H_CEILING_CRITICAL: 100, COMPLAINT_24H_CEILING_WARNING: 10, COMPLAINT_24H_CEILING_CRITICAL: 25, // 7-day absolute ceilings BOUNCE_7DAY_CEILING_WARNING: 200, BOUNCE_7DAY_CEILING_CRITICAL: 500, COMPLAINT_7DAY_CEILING_WARNING: 30, COMPLAINT_7DAY_CEILING_CRITICAL: 75, // === New project thresholds (projects < 30 days old) === // Legitimate senders ramp up gradually; spammers blast immediately NEW_PROJECT_AGE_DAYS: 30, NEW_PROJECT_BOUNCE_24H_CEILING_WARNING: 10, NEW_PROJECT_BOUNCE_24H_CEILING_CRITICAL: 25, NEW_PROJECT_BOUNCE_7DAY_CEILING_WARNING: 25, NEW_PROJECT_BOUNCE_7DAY_CEILING_CRITICAL: 50, NEW_PROJECT_COMPLAINT_24H_CEILING_WARNING: 3, NEW_PROJECT_COMPLAINT_24H_CEILING_CRITICAL: 7, NEW_PROJECT_COMPLAINT_7DAY_CEILING_WARNING: 10, NEW_PROJECT_COMPLAINT_7DAY_CEILING_CRITICAL: 20, } as const; interface RateData { total: number; bounces: number; complaints: number; bounceRate: number; complaintRate: number; } interface SecurityStatus { projectId: string; isHealthy: boolean; shouldDisable: boolean; twentyFourHour: RateData; sevenDay: RateData; allTime: RateData; isNewProject: boolean; violations: string[]; warnings: string[]; } export class SecurityService { private static readonly CACHE_TTL = 300; // 5 minutes /** * Get security status for a project (with caching) */ public static async getSecurityStatus(projectId: string): Promise { try { // Try to get from cache first const cacheKey = Keys.Security.rates(projectId); const cached = await redis.get(cacheKey); if (cached) { return JSON.parse(cached); } // Calculate fresh data const status = await this.calculateSecurityStatus(projectId); // Cache the result await redis.setex(cacheKey, this.CACHE_TTL, JSON.stringify(status)); return status; } catch (error) { signale.error('[SECURITY] Failed to get security status:', error); // Return safe defaults on error return { projectId, isHealthy: true, shouldDisable: false, twentyFourHour: { total: 0, bounces: 0, complaints: 0, bounceRate: 0, complaintRate: 0, }, sevenDay: { total: 0, bounces: 0, complaints: 0, bounceRate: 0, complaintRate: 0, }, allTime: { total: 0, bounces: 0, complaints: 0, bounceRate: 0, complaintRate: 0, }, isNewProject: false, violations: [], warnings: [], }; } } /** * Check security status and auto-disable project if thresholds are exceeded * This should be called after bounce/complaint events are processed */ public static async checkAndEnforceSecurityLimits(projectId: string): Promise { try { // Invalidate cache to get fresh data await this.invalidateCache(projectId); // Get current security status const status = await this.getSecurityStatus(projectId); // If project should be disabled, disable it (only if auto-disable is enabled) if (status.shouldDisable && AUTO_PROJECT_DISABLE) { await this.disableProject(projectId, status); } else if (status.shouldDisable && !AUTO_PROJECT_DISABLE) { // Log critical violations but don't auto-disable (self-hosted mode) const project = await prisma.project.findUnique({ where: {id: projectId}, select: {name: true}, }); if (project) { signale.error( `[SECURITY] Project ${projectId} (${project.name}) has CRITICAL security violations but auto-disable is turned off:`, status.violations, ); signale.info( `[SECURITY] 24-hour stats: ${status.twentyFourHour.bounces} bounces, ${status.twentyFourHour.complaints} complaints out of ${status.twentyFourHour.total} emails`, ); signale.info( `[SECURITY] 7-day stats: ${status.sevenDay.bounces} bounces, ${status.sevenDay.complaints} complaints out of ${status.sevenDay.total} emails`, ); signale.info( `[SECURITY] All-time stats: ${status.allTime.bounces} bounces, ${status.allTime.complaints} complaints out of ${status.allTime.total} emails`, ); if (status.isNewProject) { signale.info( `[SECURITY] Project is under ${SECURITY_THRESHOLDS.NEW_PROJECT_AGE_DAYS} days old — stricter ceilings apply`, ); } // Send notification about critical security violations await NtfyService.notifySecurityWarning(project.name, projectId, status.violations); } } else if (status.warnings.length > 0) { // Log warnings for monitoring signale.warn(`[SECURITY] Project ${projectId} has security warnings:`, status.warnings); // Get project name for notification const project = await prisma.project.findUnique({ where: {id: projectId}, select: {name: true}, }); if (project) { // Send notification about security warning await NtfyService.notifySecurityWarning(project.name, projectId, status.warnings); } } } catch (error) { // Log error but don't throw - we don't want security checks to break the webhook signale.error(`[SECURITY] Failed to check security limits for project ${projectId}:`, error); } } /** * Invalidate cached security data for a project * Should be called after bounce/complaint events */ public static async invalidateCache(projectId: string): Promise { try { const cacheKey = Keys.Security.rates(projectId); await redis.del(cacheKey); } catch (error) { signale.error(`[SECURITY] Failed to invalidate cache for project ${projectId}:`, error); } } /** * Check if a user is a member of any disabled project * Users with disabled projects cannot create new projects */ public static async userHasDisabledProject(userId: string): Promise<{ hasDisabledProject: boolean; disabledProjectNames: string[]; }> { return MembershipService.userHasDisabledProject(userId); } /** * Check if a specific project is disabled */ public static async isProjectDisabled(projectId: string): Promise { const project = await prisma.project.findUnique({ where: {id: projectId}, select: {disabled: true}, }); return project?.disabled ?? false; } /** * Get a project's security metrics (for dashboard display) * Does NOT expose internal thresholds — only computed health levels */ public static async getProjectSecurityMetrics(projectId: string): Promise<{ status: SecurityStatus; levels: { bounce7Day: 'healthy' | 'warning' | 'critical'; bounceAllTime: 'healthy' | 'warning' | 'critical'; complaint7Day: 'healthy' | 'warning' | 'critical'; complaintAllTime: 'healthy' | 'warning' | 'critical'; }; isDisabled: boolean; }> { const [status, project] = await Promise.all([ this.getSecurityStatus(projectId), prisma.project.findUnique({ where: {id: projectId}, select: {disabled: true}, }), ]); // Strip internal details from the client-facing response: // - Replace detailed violation/warning messages (they contain exact thresholds) // - Remove 24-hour data and new project flag (reveals enforcement windows) const sanitizedStatus: SecurityStatus = { ...status, twentyFourHour: {total: 0, bounces: 0, complaints: 0, bounceRate: 0, complaintRate: 0}, isNewProject: false, violations: status.violations.map(() => 'Security threshold exceeded'), warnings: status.warnings.map(() => 'Approaching security threshold'), }; return { status: sanitizedStatus, levels: { bounce7Day: this.computeLevel( status.sevenDay.bounceRate, SECURITY_THRESHOLDS.BOUNCE_7DAY_WARNING, SECURITY_THRESHOLDS.BOUNCE_7DAY_CRITICAL, ), bounceAllTime: this.computeLevel( status.allTime.bounceRate, SECURITY_THRESHOLDS.BOUNCE_ALLTIME_WARNING, SECURITY_THRESHOLDS.BOUNCE_ALLTIME_CRITICAL, ), complaint7Day: this.computeLevel( status.sevenDay.complaintRate, SECURITY_THRESHOLDS.COMPLAINT_7DAY_WARNING, SECURITY_THRESHOLDS.COMPLAINT_7DAY_CRITICAL, ), complaintAllTime: this.computeLevel( status.allTime.complaintRate, SECURITY_THRESHOLDS.COMPLAINT_ALLTIME_WARNING, SECURITY_THRESHOLDS.COMPLAINT_ALLTIME_CRITICAL, ), }, isDisabled: project?.disabled ?? false, }; } private static computeLevel( value: number, warningThreshold: number, criticalThreshold: number, ): 'healthy' | 'warning' | 'critical' { if (value >= criticalThreshold) return 'critical'; if (value >= warningThreshold) return 'warning'; return 'healthy'; } /** * Calculate bounce and complaint rates for a project */ private static async calculateRates(projectId: string, startDate?: Date): Promise { const where = { projectId, ...(startDate && { createdAt: { gte: startDate, }, }), }; // Get counts in parallel for performance const [total, bounces, complaints] = await Promise.all([ prisma.email.count({where}), prisma.email.count({ where: { ...where, bouncedAt: {not: null}, }, }), prisma.email.count({ where: { ...where, complainedAt: {not: null}, }, }), ]); const bounceRate = total > 0 ? (bounces / total) * 100 : 0; const complaintRate = total > 0 ? (complaints / total) * 100 : 0; return { total, bounces, complaints, bounceRate, complaintRate, }; } /** * Calculate security status without caching */ private static async calculateSecurityStatus(projectId: string): Promise { const now = new Date(); const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000); const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); // Get project age to determine if stricter new-project thresholds apply const project = await prisma.project.findUnique({ where: {id: projectId}, select: {createdAt: true}, }); const projectAgeDays = project ? (now.getTime() - project.createdAt.getTime()) / (1000 * 60 * 60 * 24) : Infinity; const isNewProject = projectAgeDays < SECURITY_THRESHOLDS.NEW_PROJECT_AGE_DAYS; // Get 24-hour, 7-day and all-time rates in parallel const [twentyFourHour, sevenDay, allTime] = await Promise.all([ this.calculateRates(projectId, oneDayAgo), this.calculateRates(projectId, sevenDaysAgo), this.calculateRates(projectId), ]); const violations: string[] = []; const warnings: string[] = []; // Pick absolute count ceilings based on project age const bounceCeilings = isNewProject ? { ceiling24hWarning: SECURITY_THRESHOLDS.NEW_PROJECT_BOUNCE_24H_CEILING_WARNING, ceiling24hCritical: SECURITY_THRESHOLDS.NEW_PROJECT_BOUNCE_24H_CEILING_CRITICAL, ceiling7dWarning: SECURITY_THRESHOLDS.NEW_PROJECT_BOUNCE_7DAY_CEILING_WARNING, ceiling7dCritical: SECURITY_THRESHOLDS.NEW_PROJECT_BOUNCE_7DAY_CEILING_CRITICAL, } : { ceiling24hWarning: SECURITY_THRESHOLDS.BOUNCE_24H_CEILING_WARNING, ceiling24hCritical: SECURITY_THRESHOLDS.BOUNCE_24H_CEILING_CRITICAL, ceiling7dWarning: SECURITY_THRESHOLDS.BOUNCE_7DAY_CEILING_WARNING, ceiling7dCritical: SECURITY_THRESHOLDS.BOUNCE_7DAY_CEILING_CRITICAL, }; const complaintCeilings = isNewProject ? { ceiling24hWarning: SECURITY_THRESHOLDS.NEW_PROJECT_COMPLAINT_24H_CEILING_WARNING, ceiling24hCritical: SECURITY_THRESHOLDS.NEW_PROJECT_COMPLAINT_24H_CEILING_CRITICAL, ceiling7dWarning: SECURITY_THRESHOLDS.NEW_PROJECT_COMPLAINT_7DAY_CEILING_WARNING, ceiling7dCritical: SECURITY_THRESHOLDS.NEW_PROJECT_COMPLAINT_7DAY_CEILING_CRITICAL, } : { ceiling24hWarning: SECURITY_THRESHOLDS.COMPLAINT_24H_CEILING_WARNING, ceiling24hCritical: SECURITY_THRESHOLDS.COMPLAINT_24H_CEILING_CRITICAL, ceiling7dWarning: SECURITY_THRESHOLDS.COMPLAINT_7DAY_CEILING_WARNING, ceiling7dCritical: SECURITY_THRESHOLDS.COMPLAINT_7DAY_CEILING_CRITICAL, }; const projectLabel = isNewProject ? ' (new project)' : ''; // === Absolute count ceiling checks (rate-independent) === // These catch high-volume spammers who dilute their bounce rate by blasting emails // 24-hour bounce ceilings if (twentyFourHour.bounces >= bounceCeilings.ceiling24hCritical) { violations.push( `24-hour bounce count${projectLabel} (${twentyFourHour.bounces} bounces) exceeds critical ceiling (${bounceCeilings.ceiling24hCritical})`, ); } else if (twentyFourHour.bounces >= bounceCeilings.ceiling24hWarning) { warnings.push( `24-hour bounce count${projectLabel} (${twentyFourHour.bounces} bounces) exceeds warning ceiling (${bounceCeilings.ceiling24hWarning})`, ); } // 7-day bounce ceilings if (sevenDay.bounces >= bounceCeilings.ceiling7dCritical) { violations.push( `7-day bounce count${projectLabel} (${sevenDay.bounces} bounces) exceeds critical ceiling (${bounceCeilings.ceiling7dCritical})`, ); } else if (sevenDay.bounces >= bounceCeilings.ceiling7dWarning) { warnings.push( `7-day bounce count${projectLabel} (${sevenDay.bounces} bounces) exceeds warning ceiling (${bounceCeilings.ceiling7dWarning})`, ); } // 24-hour complaint ceilings if (twentyFourHour.complaints >= complaintCeilings.ceiling24hCritical) { violations.push( `24-hour complaint count${projectLabel} (${twentyFourHour.complaints} complaints) exceeds critical ceiling (${complaintCeilings.ceiling24hCritical})`, ); } else if (twentyFourHour.complaints >= complaintCeilings.ceiling24hWarning) { warnings.push( `24-hour complaint count${projectLabel} (${twentyFourHour.complaints} complaints) exceeds warning ceiling (${complaintCeilings.ceiling24hWarning})`, ); } // 7-day complaint ceilings if (sevenDay.complaints >= complaintCeilings.ceiling7dCritical) { violations.push( `7-day complaint count${projectLabel} (${sevenDay.complaints} complaints) exceeds critical ceiling (${complaintCeilings.ceiling7dCritical})`, ); } else if (sevenDay.complaints >= complaintCeilings.ceiling7dWarning) { warnings.push( `7-day complaint count${projectLabel} (${sevenDay.complaints} complaints) exceeds warning ceiling (${complaintCeilings.ceiling7dWarning})`, ); } // === Rate-based checks (existing logic) === // Only enforce if minimum emails threshold is met const hasMinimumVolumeAllTime = allTime.total >= SECURITY_THRESHOLDS.MIN_EMAILS_FOR_ENFORCEMENT; const hasMinimumVolume7Day = sevenDay.total >= SECURITY_THRESHOLDS.MIN_EMAILS_FOR_ENFORCEMENT; // Check 7-day bounce rate (only if 7-day volume is sufficient) if (hasMinimumVolume7Day) { if ( sevenDay.bounceRate >= SECURITY_THRESHOLDS.BOUNCE_7DAY_CRITICAL && sevenDay.bounces >= SECURITY_THRESHOLDS.MIN_BOUNCES_FOR_CRITICAL ) { violations.push( `7-day bounce rate (${sevenDay.bounceRate.toFixed(2)}%, ${sevenDay.bounces} bounces) exceeds critical threshold (${SECURITY_THRESHOLDS.BOUNCE_7DAY_CRITICAL}%, ${SECURITY_THRESHOLDS.MIN_BOUNCES_FOR_CRITICAL} minimum)`, ); } else if ( sevenDay.bounceRate >= SECURITY_THRESHOLDS.BOUNCE_7DAY_WARNING && sevenDay.bounces >= SECURITY_THRESHOLDS.MIN_BOUNCES_FOR_WARNING ) { warnings.push( `7-day bounce rate (${sevenDay.bounceRate.toFixed(2)}%, ${sevenDay.bounces} bounces) exceeds warning threshold (${SECURITY_THRESHOLDS.BOUNCE_7DAY_WARNING}%, ${SECURITY_THRESHOLDS.MIN_BOUNCES_FOR_WARNING} minimum)`, ); } } // Check 7-day complaint rate (only if 7-day volume is sufficient) if (hasMinimumVolume7Day) { if ( sevenDay.complaintRate >= SECURITY_THRESHOLDS.COMPLAINT_7DAY_CRITICAL && sevenDay.complaints >= SECURITY_THRESHOLDS.MIN_COMPLAINTS_FOR_CRITICAL ) { violations.push( `7-day complaint rate (${sevenDay.complaintRate.toFixed(3)}%, ${sevenDay.complaints} complaints) exceeds critical threshold (${SECURITY_THRESHOLDS.COMPLAINT_7DAY_CRITICAL}%, ${SECURITY_THRESHOLDS.MIN_COMPLAINTS_FOR_CRITICAL} minimum)`, ); } else if ( sevenDay.complaintRate >= SECURITY_THRESHOLDS.COMPLAINT_7DAY_WARNING && sevenDay.complaints >= SECURITY_THRESHOLDS.MIN_COMPLAINTS_FOR_WARNING ) { warnings.push( `7-day complaint rate (${sevenDay.complaintRate.toFixed(3)}%, ${sevenDay.complaints} complaints) exceeds warning threshold (${SECURITY_THRESHOLDS.COMPLAINT_7DAY_WARNING}%, ${SECURITY_THRESHOLDS.MIN_COMPLAINTS_FOR_WARNING} minimum)`, ); } } // Check all-time rates (only if all-time volume is sufficient) if (hasMinimumVolumeAllTime) { if ( allTime.bounceRate >= SECURITY_THRESHOLDS.BOUNCE_ALLTIME_CRITICAL && allTime.bounces >= SECURITY_THRESHOLDS.MIN_BOUNCES_FOR_CRITICAL ) { violations.push( `All-time bounce rate (${allTime.bounceRate.toFixed(2)}%, ${allTime.bounces} bounces) exceeds critical threshold (${SECURITY_THRESHOLDS.BOUNCE_ALLTIME_CRITICAL}%, ${SECURITY_THRESHOLDS.MIN_BOUNCES_FOR_CRITICAL} minimum)`, ); } else if ( allTime.bounceRate >= SECURITY_THRESHOLDS.BOUNCE_ALLTIME_WARNING && allTime.bounces >= SECURITY_THRESHOLDS.MIN_BOUNCES_FOR_WARNING ) { warnings.push( `All-time bounce rate (${allTime.bounceRate.toFixed(2)}%, ${allTime.bounces} bounces) exceeds warning threshold (${SECURITY_THRESHOLDS.BOUNCE_ALLTIME_WARNING}%, ${SECURITY_THRESHOLDS.MIN_BOUNCES_FOR_WARNING} minimum)`, ); } if ( allTime.complaintRate >= SECURITY_THRESHOLDS.COMPLAINT_ALLTIME_CRITICAL && allTime.complaints >= SECURITY_THRESHOLDS.MIN_COMPLAINTS_FOR_CRITICAL ) { violations.push( `All-time complaint rate (${allTime.complaintRate.toFixed(3)}%, ${allTime.complaints} complaints) exceeds critical threshold (${SECURITY_THRESHOLDS.COMPLAINT_ALLTIME_CRITICAL}%, ${SECURITY_THRESHOLDS.MIN_COMPLAINTS_FOR_CRITICAL} minimum)`, ); } else if ( allTime.complaintRate >= SECURITY_THRESHOLDS.COMPLAINT_ALLTIME_WARNING && allTime.complaints >= SECURITY_THRESHOLDS.MIN_COMPLAINTS_FOR_WARNING ) { warnings.push( `All-time complaint rate (${allTime.complaintRate.toFixed(3)}%, ${allTime.complaints} complaints) exceeds warning threshold (${SECURITY_THRESHOLDS.COMPLAINT_ALLTIME_WARNING}%, ${SECURITY_THRESHOLDS.MIN_COMPLAINTS_FOR_WARNING} minimum)`, ); } } return { projectId, isHealthy: violations.length === 0, shouldDisable: violations.length > 0, twentyFourHour, sevenDay, allTime, isNewProject, violations, warnings, }; } /** * Disable a project due to security violations */ private static async disableProject(projectId: string, status: SecurityStatus): Promise { try { // Check if already disabled to avoid duplicate logs const project = await prisma.project.findUnique({ where: {id: projectId}, select: {id: true, disabled: true, name: true}, }); if (!project) { signale.error(`[SECURITY] Project ${projectId} not found`); return; } if (project.disabled) { // Already disabled, just log the current violations signale.warn( `[SECURITY] Project ${projectId} (${project.name}) already disabled. Current violations:`, status.violations, ); return; } // Disable the project await prisma.project.update({ where: {id: projectId}, data: {disabled: true}, }); // Log critical security event signale.error( `[SECURITY] Project ${projectId} (${project.name}) has been automatically disabled due to security violations:`, status.violations, ); signale.info( `[SECURITY] 7-day stats: ${status.sevenDay.bounces} bounces, ${status.sevenDay.complaints} complaints out of ${status.sevenDay.total} emails`, ); signale.info( `[SECURITY] All-time stats: ${status.allTime.bounces} bounces, ${status.allTime.complaints} complaints out of ${status.allTime.total} emails`, ); // Cancel all pending jobs for this project try { await QueueService.cancelAllProjectJobs(projectId); signale.info(`[SECURITY] Cancelled all pending jobs for project ${projectId}`); } catch (error) { signale.error(`[SECURITY] Failed to cancel pending jobs for project ${projectId}:`, error); } // Send urgent notification about project suspension await NtfyService.notifyProjectDisabledForSecurity(project.name, projectId, status.violations); // Send email notification to project members try { const members = await MembershipService.getMembers(projectId); const emails = members.map(m => m.email); if (emails.length > 0) { const template = React.createElement(ProjectDisabledEmail, { projectName: project.name, projectId, violations: status.violations, dashboardUrl: DASHBOARD_URI, landingUrl: LANDING_URI, }); await Promise.all( emails.map(email => sendPlatformEmail(email, 'Project Disabled - Security Risk', template)), ); } } catch (emailError) { signale.error(`[SECURITY] Failed to send project disabled email:`, emailError); } } catch (error) { signale.error(`[SECURITY] Failed to disable project ${projectId}:`, error); } } }