diff --git a/apps/api/src/services/SecurityService.ts b/apps/api/src/services/SecurityService.ts index 27d6b0c..f2356dd 100644 --- a/apps/api/src/services/SecurityService.ts +++ b/apps/api/src/services/SecurityService.ts @@ -50,22 +50,13 @@ const SECURITY_THRESHOLDS = { 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 + // === Absolute count ceilings (new projects only) === + // These trigger regardless of rate — catches new accounts blasting emails + // before their bounce rate has caught up. Established projects rely on + // rate-based checks only, since high absolute counts at high volume + // (e.g. 100 bounces out of 10K) don't indicate abuse. + // + // 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, @@ -516,82 +507,54 @@ export class SecurityService { 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, - }; + // === Absolute count ceiling checks (new projects only, rate-independent) === + // Catches new accounts blasting emails before their bounce rate catches up. + // Established projects skip these — high absolute counts at high volume + // (e.g. 100 bounces out of 10K) don't indicate abuse; rate checks handle them. + if (isNewProject) { + // 24-hour bounce ceilings + if (twentyFourHour.bounces >= SECURITY_THRESHOLDS.NEW_PROJECT_BOUNCE_24H_CEILING_CRITICAL) { + violations.push( + `24-hour bounce count (new project) (${twentyFourHour.bounces} bounces) exceeds critical ceiling (${SECURITY_THRESHOLDS.NEW_PROJECT_BOUNCE_24H_CEILING_CRITICAL})`, + ); + } else if (twentyFourHour.bounces >= SECURITY_THRESHOLDS.NEW_PROJECT_BOUNCE_24H_CEILING_WARNING) { + warnings.push( + `24-hour bounce count (new project) (${twentyFourHour.bounces} bounces) exceeds warning ceiling (${SECURITY_THRESHOLDS.NEW_PROJECT_BOUNCE_24H_CEILING_WARNING})`, + ); + } - 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, - }; + // 7-day bounce ceilings + if (sevenDay.bounces >= SECURITY_THRESHOLDS.NEW_PROJECT_BOUNCE_7DAY_CEILING_CRITICAL) { + violations.push( + `7-day bounce count (new project) (${sevenDay.bounces} bounces) exceeds critical ceiling (${SECURITY_THRESHOLDS.NEW_PROJECT_BOUNCE_7DAY_CEILING_CRITICAL})`, + ); + } else if (sevenDay.bounces >= SECURITY_THRESHOLDS.NEW_PROJECT_BOUNCE_7DAY_CEILING_WARNING) { + warnings.push( + `7-day bounce count (new project) (${sevenDay.bounces} bounces) exceeds warning ceiling (${SECURITY_THRESHOLDS.NEW_PROJECT_BOUNCE_7DAY_CEILING_WARNING})`, + ); + } - const projectLabel = isNewProject ? ' (new project)' : ''; + // 24-hour complaint ceilings + if (twentyFourHour.complaints >= SECURITY_THRESHOLDS.NEW_PROJECT_COMPLAINT_24H_CEILING_CRITICAL) { + violations.push( + `24-hour complaint count (new project) (${twentyFourHour.complaints} complaints) exceeds critical ceiling (${SECURITY_THRESHOLDS.NEW_PROJECT_COMPLAINT_24H_CEILING_CRITICAL})`, + ); + } else if (twentyFourHour.complaints >= SECURITY_THRESHOLDS.NEW_PROJECT_COMPLAINT_24H_CEILING_WARNING) { + warnings.push( + `24-hour complaint count (new project) (${twentyFourHour.complaints} complaints) exceeds warning ceiling (${SECURITY_THRESHOLDS.NEW_PROJECT_COMPLAINT_24H_CEILING_WARNING})`, + ); + } - // === 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})`, - ); + // 7-day complaint ceilings + if (sevenDay.complaints >= SECURITY_THRESHOLDS.NEW_PROJECT_COMPLAINT_7DAY_CEILING_CRITICAL) { + violations.push( + `7-day complaint count (new project) (${sevenDay.complaints} complaints) exceeds critical ceiling (${SECURITY_THRESHOLDS.NEW_PROJECT_COMPLAINT_7DAY_CEILING_CRITICAL})`, + ); + } else if (sevenDay.complaints >= SECURITY_THRESHOLDS.NEW_PROJECT_COMPLAINT_7DAY_CEILING_WARNING) { + warnings.push( + `7-day complaint count (new project) (${sevenDay.complaints} complaints) exceeds warning ceiling (${SECURITY_THRESHOLDS.NEW_PROJECT_COMPLAINT_7DAY_CEILING_WARNING})`, + ); + } } // === Rate-based checks (existing logic) === diff --git a/apps/api/src/services/__tests__/SecurityService.test.ts b/apps/api/src/services/__tests__/SecurityService.test.ts index f781ed8..6bd1542 100644 --- a/apps/api/src/services/__tests__/SecurityService.test.ts +++ b/apps/api/src/services/__tests__/SecurityService.test.ts @@ -109,14 +109,12 @@ describe('SecurityService', () => { await createEmails(50, {bouncedCount: 10}); const status = await SecurityService.getSecurityStatus(projectId); - // Rate-based check doesn't trigger, but absolute count ceiling might - // With 10 bounces in 24h, this is below the 50-bounce ceiling for established projects expect(status.violations).toHaveLength(0); }); }); - describe('Absolute count ceilings (established projects)', () => { - // Age the project past the new-project window so standard ceilings apply + describe('Established projects skip absolute ceilings', () => { + // Age the project past the new-project window beforeEach(async () => { const oldDate = new Date(Date.now() - 31 * 24 * 60 * 60 * 1000); await prisma.project.update({ @@ -125,45 +123,28 @@ describe('SecurityService', () => { }); }); - it('should trigger critical when 24-hour bounce count exceeds ceiling', async () => { - // 20,000 emails, 101 bounces = 0.5% rate (well below rate threshold) - // But 101 bounces > 100 (24h critical ceiling for established projects) - await createEmails(20000, {bouncedCount: 101}); - - const status = await SecurityService.getSecurityStatus(projectId); - expect(status.shouldDisable).toBe(true); - expect(status.violations.some(v => v.includes('24-hour bounce count'))).toBe(true); - }); - - it('should trigger warning when 24-hour bounce count exceeds warning ceiling', async () => { - // 10,000 emails, 51 bounces = 0.51% (below rate threshold) - // But 51 > 50 (24h warning ceiling), below 100 critical - await createEmails(10000, {bouncedCount: 51}); - - const status = await SecurityService.getSecurityStatus(projectId); - expect(status.isHealthy).toBe(true); // warnings don't make it unhealthy - expect(status.warnings.some(w => w.includes('24-hour bounce count'))).toBe(true); - }); - - it('should trigger critical when 24-hour complaint count exceeds ceiling', async () => { - // 20,000 emails, 26 complaints = 0.13% (below complaint rate critical of 0.15%) - // But 26 > 25 (24h complaint critical ceiling) - await createEmails(20000, {complainedCount: 26}); - - const status = await SecurityService.getSecurityStatus(projectId); - expect(status.shouldDisable).toBe(true); - expect(status.violations.some(v => v.includes('24-hour complaint count'))).toBe(true); - }); - - it('should NOT trigger ceiling when bounce count is below ceiling', async () => { - // 20,000 emails, 40 bounces = below 50 warning ceiling for established projects - await createEmails(20000, {bouncedCount: 40}); + it('should NOT trigger on high absolute bounce count when rate is healthy', async () => { + // 20,000 emails, 200 bounces = 1% rate (well below rate threshold) + // Established projects rely solely on rates — high absolute counts at + // high volume don't indicate abuse. + await createEmails(20000, {bouncedCount: 200}); const status = await SecurityService.getSecurityStatus(projectId); expect(status.isHealthy).toBe(true); + expect(status.shouldDisable).toBe(false); expect(status.violations).toHaveLength(0); expect(status.warnings).toHaveLength(0); }); + + it('should NOT trigger on high absolute complaint count when rate is healthy', async () => { + // 20,000 emails, 50 complaints = 0.25% (above warning 0.075%, would have + // tripped old absolute ceiling). With rate-only enforcement and rate + // above warning but below critical, this is a warning, not a violation. + await createEmails(20000, {complainedCount: 50}); + + const status = await SecurityService.getSecurityStatus(projectId); + expect(status.shouldDisable).toBe(false); + }); }); describe('New project stricter thresholds', () => { @@ -178,7 +159,7 @@ describe('SecurityService', () => { expect(status.violations.some(v => v.includes('new project'))).toBe(true); }); - it('should apply standard ceilings for projects over 30 days old', async () => { + it('should NOT apply absolute ceilings for projects over 30 days old', async () => { // Age the project to 31 days const oldDate = new Date(Date.now() - 31 * 24 * 60 * 60 * 1000); await prisma.project.update({ @@ -186,15 +167,14 @@ describe('SecurityService', () => { data: {createdAt: oldDate}, }); - // 10,000 emails, 26 bounces (above 25 new project ceiling, below 50 standard warning ceiling) + // 10,000 emails, 26 bounces — would trip new-project ceiling, but + // established projects skip ceilings entirely (rate is 0.26%, healthy). await createEmails(10000, {bouncedCount: 26}); const status = await SecurityService.getSecurityStatus(projectId); expect(status.isNewProject).toBe(false); - // 26 is below the 50-bounce 24h warning ceiling for established projects - expect(status.warnings.some(w => w.includes('24-hour bounce count'))).toBe(false); - // And below the 100-bounce 24h critical ceiling - expect(status.violations.some(v => v.includes('24-hour bounce count'))).toBe(false); + expect(status.warnings.some(w => w.includes('bounce count'))).toBe(false); + expect(status.violations.some(v => v.includes('bounce count'))).toBe(false); }); it('should catch new project blasting emails with delayed bounces', async () => { @@ -212,8 +192,8 @@ describe('SecurityService', () => { describe('checkAndEnforceSecurityLimits', () => { it('should disable project when critical thresholds are exceeded', async () => { - // Create enough bounces to trigger critical - await createEmails(20000, {bouncedCount: 101}); + // New project, 20K emails with 30 bounces — exceeds new project 24h critical ceiling + await createEmails(20000, {bouncedCount: 30}); await SecurityService.checkAndEnforceSecurityLimits(projectId); @@ -225,13 +205,13 @@ describe('SecurityService', () => { }); it('should NOT disable project when only warnings exist', async () => { - // 10,000 emails, 51 bounces (above warning but below critical for established project) + // Established project, 200 emails, 12 bounces = 6% (above 5% warning, below 10% critical) const oldDate = new Date(Date.now() - 31 * 24 * 60 * 60 * 1000); await prisma.project.update({ where: {id: projectId}, data: {createdAt: oldDate}, }); - await createEmails(10000, {bouncedCount: 51}); + await createEmails(200, {bouncedCount: 12}); await SecurityService.checkAndEnforceSecurityLimits(projectId);