diff --git a/server/src/routes/auth.js b/server/src/routes/auth.js index 091c5dc..18e55f3 100644 --- a/server/src/routes/auth.js +++ b/server/src/routes/auth.js @@ -2,7 +2,7 @@ const express = require('express'); const { v4: uuidv4 } = require('uuid'); const config = require('../config'); const storage = require('../storage'); -const { sendMagicLinkEmail } = require('../services/email'); +const emailService = require('../services/email'); const { generateSessionToken } = require('../services/jwt'); const { checkPremiumStatus } = require('../services/stripe'); const { renderPage } = require('../templates'); @@ -57,7 +57,19 @@ router.post('/send-magic-link', async (req, res) => { return res.status(400).json({ error: 'Invalid email format' }); } - // Check rate limit + // Check IP rate limit + const ip = req.ip || req.connection.remoteAddress; + const ipLimit = storage.checkIpRateLimit(ip); + if (!ipLimit.allowed) { + const retryAfter = Math.ceil((ipLimit.resetTime - Date.now()) / 1000); + res.set('Retry-After', retryAfter); + return res.status(429).json({ + error: 'Too many requests. Please try again later.', + retryAfter, + }); + } + + // Check per-email rate limit const rateLimit = storage.checkRateLimit(email); if (!rateLimit.allowed) { const retryAfter = Math.ceil((rateLimit.resetTime - Date.now()) / 1000); @@ -68,15 +80,21 @@ router.post('/send-magic-link', async (req, res) => { }); } + // Validate email with SES insights + const validation = await emailService.validateEmail(email); + if (!validation.valid) { + return res.status(400).json({ error: 'This email address appears to be invalid.' }); + } + // Generate request ID and create auth request const requestId = uuidv4(); - storage.createAuthRequest(requestId, email); + storage.createAuthRequest(requestId, email, { ip }); // Send magic link email const magicLinkUrl = `${config.BASE_URL}/auth/verify?token=${requestId}`; try { const status = await checkPremiumStatus(email); - await sendMagicLinkEmail(email, magicLinkUrl, { isPremium: status.premium }); + await emailService.sendMagicLinkEmail(email, magicLinkUrl, { isPremium: status.premium }); } catch (err) { storage.deleteAuthRequest(requestId); throw err; @@ -118,6 +136,9 @@ router.get('/verify', async (req, res) => { session_token: sessionToken, }); storage.decrementRateLimit(authRequest.email); + if (authRequest.ip) { + storage.decrementIpRateLimit(authRequest.ip); + } console.log(`[auth] Email verified: ${authRequest.email}`); } diff --git a/server/src/services/email.js b/server/src/services/email.js index 7ebd49f..04e127a 100644 --- a/server/src/services/email.js +++ b/server/src/services/email.js @@ -1,7 +1,9 @@ const { SESClient, SendEmailCommand } = require('@aws-sdk/client-ses'); +const { SESv2Client, GetEmailAddressInsightsCommand } = require('@aws-sdk/client-sesv2'); const config = require('../config'); const sesClient = new SESClient({ region: config.AWS_REGION }); +const sesV2Client = new SESv2Client({ region: config.AWS_REGION }); async function sendMagicLinkEmail(email, magicLinkUrl, { isPremium = false } = {}) { const premiumBadge = isPremium @@ -188,8 +190,26 @@ async function sendCancellationEmail(email) { return sesClient.send(command); } +async function validateEmail(email) { + try { + const command = new GetEmailAddressInsightsCommand({ EmailAddress: email }); + const result = await sesV2Client.send(command, { + abortSignal: AbortSignal.timeout(2000), + }); + const verdict = result.MailboxValidation?.IsValid?.ConfidenceVerdict; + if (verdict === 'LOW') { + return { valid: false, reason: `Low confidence: ${email}` }; + } + return { valid: true }; + } catch (err) { + console.warn('[email] validateEmail error, failing open:', err.message); + return { valid: true }; + } +} + module.exports = { sendMagicLinkEmail, sendWelcomeEmail, sendCancellationEmail, + validateEmail, }; diff --git a/server/tests/auth.test.js b/server/tests/auth.test.js index 80c0d3d..7bbeead 100644 --- a/server/tests/auth.test.js +++ b/server/tests/auth.test.js @@ -1,4 +1,4 @@ -const { describe, it, before, after, beforeEach } = require('node:test'); +const { describe, it, after, beforeEach } = require('node:test'); const assert = require('node:assert'); const request = require('supertest'); @@ -25,6 +25,7 @@ describe('Auth Routes', () => { cleanTestData(); storage.ensureDirectories(); lastSentEmail = null; + emailService.validateEmail = async () => ({ valid: true }); }); after(() => { @@ -60,6 +61,17 @@ describe('Auth Routes', () => { assert.strictEqual(res.status, 400); }); + it('should reject email with low confidence from SES insights', async () => { + emailService.validateEmail = async () => ({ valid: false, reason: 'Low confidence' }); + + const res = await request(app) + .post('/auth/send-magic-link') + .send({ email: 'bad@disposable.com' }); + + assert.strictEqual(res.status, 400); + assert.ok(res.body.error.includes('invalid')); + }); + it('should rate limit after too many requests', async () => { const email = 'ratelimit@example.com'; diff --git a/server/tests/email.test.js b/server/tests/email.test.js new file mode 100644 index 0000000..6fd0045 --- /dev/null +++ b/server/tests/email.test.js @@ -0,0 +1,77 @@ +const { describe, it, beforeEach, mock } = require('node:test'); +const assert = require('node:assert'); + +// Setup must be required first +require('./setup'); + +describe('Email Service - validateEmail', () => { + let emailModule; + let mockSend; + + beforeEach(() => { + // Clear module cache so we get a fresh instance with mockable client + delete require.cache[require.resolve('../src/services/email')]; + delete require.cache[require.resolve('@aws-sdk/client-sesv2')]; + + // Mock the SESv2 client before requiring email module + const sesv2 = require('@aws-sdk/client-sesv2'); + mockSend = mock.fn(); + const OriginalClient = sesv2.SESv2Client; + sesv2.SESv2Client = class MockSESv2Client extends OriginalClient { + constructor(...args) { + super(...args); + this.send = mockSend; + } + }; + + emailModule = require('../src/services/email'); + }); + + it('should return valid for HIGH confidence verdict', async () => { + mockSend.mock.mockImplementation(async () => ({ + MailboxValidation: { + IsValid: { ConfidenceVerdict: 'HIGH' }, + }, + })); + + const result = await emailModule.validateEmail('good@example.com'); + assert.deepStrictEqual(result, { valid: true }); + }); + + it('should return invalid for LOW confidence verdict', async () => { + mockSend.mock.mockImplementation(async () => ({ + MailboxValidation: { + IsValid: { ConfidenceVerdict: 'LOW' }, + }, + })); + + const result = await emailModule.validateEmail('bad@disposable.com'); + assert.strictEqual(result.valid, false); + assert.ok(result.reason.includes('Low confidence')); + }); + + it('should return valid when MailboxValidation is missing', async () => { + mockSend.mock.mockImplementation(async () => ({})); + + const result = await emailModule.validateEmail('unknown@example.com'); + assert.deepStrictEqual(result, { valid: true }); + }); + + it('should fail open on SES error', async () => { + mockSend.mock.mockImplementation(async () => { + throw new Error('SES unavailable'); + }); + + const result = await emailModule.validateEmail('error@example.com'); + assert.deepStrictEqual(result, { valid: true }); + }); + + it('should fail open on timeout', async () => { + mockSend.mock.mockImplementation(async () => { + throw new DOMException('The operation was aborted', 'AbortError'); + }); + + const result = await emailModule.validateEmail('slow@example.com'); + assert.deepStrictEqual(result, { valid: true }); + }); +});