Add email validation, IP rate limiting, and fix SES parsing bug

Add SES email validation with 2s timeout and fail-open behavior to
send-magic-link. Add per-IP rate limiting with decrement on verify.
Fix SES response parsing path (MailboxValidation.IsValid.ConfidenceVerdict).
Add dedicated unit tests for validateEmail.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Lawrence Hook
2026-03-01 23:48:01 -05:00
parent 84b103e46b
commit b433727754
4 changed files with 135 additions and 5 deletions
+25 -4
View File
@@ -2,7 +2,7 @@ const express = require('express');
const { v4: uuidv4 } = require('uuid'); const { v4: uuidv4 } = require('uuid');
const config = require('../config'); const config = require('../config');
const storage = require('../storage'); const storage = require('../storage');
const { sendMagicLinkEmail } = require('../services/email'); const emailService = require('../services/email');
const { generateSessionToken } = require('../services/jwt'); const { generateSessionToken } = require('../services/jwt');
const { checkPremiumStatus } = require('../services/stripe'); const { checkPremiumStatus } = require('../services/stripe');
const { renderPage } = require('../templates'); 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' }); 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); const rateLimit = storage.checkRateLimit(email);
if (!rateLimit.allowed) { if (!rateLimit.allowed) {
const retryAfter = Math.ceil((rateLimit.resetTime - Date.now()) / 1000); 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 // Generate request ID and create auth request
const requestId = uuidv4(); const requestId = uuidv4();
storage.createAuthRequest(requestId, email); storage.createAuthRequest(requestId, email, { ip });
// Send magic link email // Send magic link email
const magicLinkUrl = `${config.BASE_URL}/auth/verify?token=${requestId}`; const magicLinkUrl = `${config.BASE_URL}/auth/verify?token=${requestId}`;
try { try {
const status = await checkPremiumStatus(email); const status = await checkPremiumStatus(email);
await sendMagicLinkEmail(email, magicLinkUrl, { isPremium: status.premium }); await emailService.sendMagicLinkEmail(email, magicLinkUrl, { isPremium: status.premium });
} catch (err) { } catch (err) {
storage.deleteAuthRequest(requestId); storage.deleteAuthRequest(requestId);
throw err; throw err;
@@ -118,6 +136,9 @@ router.get('/verify', async (req, res) => {
session_token: sessionToken, session_token: sessionToken,
}); });
storage.decrementRateLimit(authRequest.email); storage.decrementRateLimit(authRequest.email);
if (authRequest.ip) {
storage.decrementIpRateLimit(authRequest.ip);
}
console.log(`[auth] Email verified: ${authRequest.email}`); console.log(`[auth] Email verified: ${authRequest.email}`);
} }
+20
View File
@@ -1,7 +1,9 @@
const { SESClient, SendEmailCommand } = require('@aws-sdk/client-ses'); const { SESClient, SendEmailCommand } = require('@aws-sdk/client-ses');
const { SESv2Client, GetEmailAddressInsightsCommand } = require('@aws-sdk/client-sesv2');
const config = require('../config'); const config = require('../config');
const sesClient = new SESClient({ region: config.AWS_REGION }); const sesClient = new SESClient({ region: config.AWS_REGION });
const sesV2Client = new SESv2Client({ region: config.AWS_REGION });
async function sendMagicLinkEmail(email, magicLinkUrl, { isPremium = false } = {}) { async function sendMagicLinkEmail(email, magicLinkUrl, { isPremium = false } = {}) {
const premiumBadge = isPremium const premiumBadge = isPremium
@@ -188,8 +190,26 @@ async function sendCancellationEmail(email) {
return sesClient.send(command); 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 = { module.exports = {
sendMagicLinkEmail, sendMagicLinkEmail,
sendWelcomeEmail, sendWelcomeEmail,
sendCancellationEmail, sendCancellationEmail,
validateEmail,
}; };
+13 -1
View File
@@ -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 assert = require('node:assert');
const request = require('supertest'); const request = require('supertest');
@@ -25,6 +25,7 @@ describe('Auth Routes', () => {
cleanTestData(); cleanTestData();
storage.ensureDirectories(); storage.ensureDirectories();
lastSentEmail = null; lastSentEmail = null;
emailService.validateEmail = async () => ({ valid: true });
}); });
after(() => { after(() => {
@@ -60,6 +61,17 @@ describe('Auth Routes', () => {
assert.strictEqual(res.status, 400); 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 () => { it('should rate limit after too many requests', async () => {
const email = 'ratelimit@example.com'; const email = 'ratelimit@example.com';
+77
View File
@@ -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 });
});
});