mirror of
https://github.com/lawrencehook/remove-youtube-suggestions.git
synced 2026-07-25 06:54:31 +00:00
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:
@@ -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}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user