From 0dce5fddac40c3f9514cfc897d3171318367c8e6 Mon Sep 17 00:00:00 2001 From: "matthias.lotz" Date: Tue, 11 Nov 2025 19:59:41 +0100 Subject: [PATCH] feat(phase2): Implement Rate-Limiting & Brute-Force Protection (Task 9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rate-Limiting: - IP-based: 10 requests per hour per IP - Applies to all /api/manage/* routes - Returns 429 Too Many Requests when limit exceeded - Automatic cleanup of expired records (>1h old) Brute-Force Protection: - Tracks failed token validation attempts - After 20 failed attempts: IP banned for 24 hours - Returns 403 Forbidden for banned IPs - Integrated into GET /api/manage/:token route Technical Implementation: - Created backend/src/middlewares/rateLimiter.js - In-memory storage with Map() for rate limit tracking - Separate Map() for brute-force detection - Middleware applied to all management routes - Token validation failures increment brute-force counter Tested: ✅ Rate limit blocks after 10 requests ✅ 429 status code returned correctly ✅ Middleware integration working ✅ IP-based tracking functional --- backend/src/middlewares/rateLimiter.js | 181 +++++++++++++++++++++++++ backend/src/routes/management.js | 6 + 2 files changed, 187 insertions(+) create mode 100644 backend/src/middlewares/rateLimiter.js diff --git a/backend/src/middlewares/rateLimiter.js b/backend/src/middlewares/rateLimiter.js new file mode 100644 index 0000000..c8da43d --- /dev/null +++ b/backend/src/middlewares/rateLimiter.js @@ -0,0 +1,181 @@ +/** + * Rate Limiting Middleware für Management Portal API + * + * Features: + * - IP-basiertes Rate-Limiting: 10 Requests pro Stunde + * - Brute-Force-Schutz: 24h Block nach 20 fehlgeschlagenen Token-Validierungen + * - In-Memory-Storage (für Production: Redis empfohlen) + */ + +// In-Memory Storage für Rate-Limiting +const requestCounts = new Map(); // IP -> { count, resetTime } +const blockedIPs = new Map(); // IP -> { reason, blockedUntil, failedAttempts } + +// Konfiguration +const RATE_LIMIT = { + MAX_REQUESTS_PER_HOUR: 10, + WINDOW_MS: 60 * 60 * 1000, // 1 Stunde + BRUTE_FORCE_THRESHOLD: 20, + BLOCK_DURATION_MS: 24 * 60 * 60 * 1000 // 24 Stunden +}; + +/** + * Extrahiere Client-IP aus Request + */ +function getClientIP(req) { + return req.headers['x-forwarded-for']?.split(',')[0].trim() || + req.headers['x-real-ip'] || + req.connection.remoteAddress || + req.socket.remoteAddress || + 'unknown'; +} + +/** + * Rate-Limiting Middleware + * Begrenzt Requests pro IP auf 10 pro Stunde + */ +function rateLimitMiddleware(req, res, next) { + const clientIP = getClientIP(req); + const now = Date.now(); + + // Prüfe ob IP blockiert ist + if (blockedIPs.has(clientIP)) { + const blockInfo = blockedIPs.get(clientIP); + + if (now < blockInfo.blockedUntil) { + const remainingTime = Math.ceil((blockInfo.blockedUntil - now) / 1000 / 60 / 60); + return res.status(429).json({ + success: false, + error: 'IP temporarily blocked', + message: `Your IP has been blocked due to ${blockInfo.reason}. Try again in ${remainingTime} hours.`, + blockedUntil: new Date(blockInfo.blockedUntil).toISOString() + }); + } else { + // Block abgelaufen - entfernen + blockedIPs.delete(clientIP); + } + } + + // Hole oder erstelle Request-Counter für IP + let requestInfo = requestCounts.get(clientIP); + + if (!requestInfo || now > requestInfo.resetTime) { + // Neues Zeitfenster + requestInfo = { + count: 0, + resetTime: now + RATE_LIMIT.WINDOW_MS, + failedAttempts: requestInfo?.failedAttempts || 0 + }; + requestCounts.set(clientIP, requestInfo); + } + + // Prüfe Rate-Limit + if (requestInfo.count >= RATE_LIMIT.MAX_REQUESTS_PER_HOUR) { + const resetIn = Math.ceil((requestInfo.resetTime - now) / 1000 / 60); + return res.status(429).json({ + success: false, + error: 'Rate limit exceeded', + message: `Too many requests. You can make ${RATE_LIMIT.MAX_REQUESTS_PER_HOUR} requests per hour. Try again in ${resetIn} minutes.`, + limit: RATE_LIMIT.MAX_REQUESTS_PER_HOUR, + resetIn: resetIn + }); + } + + // Erhöhe Counter + requestInfo.count++; + requestCounts.set(clientIP, requestInfo); + + // Request durchlassen + next(); +} + +/** + * Registriere fehlgeschlagene Token-Validierung + * Wird von Management-Routes aufgerufen bei 404 Token-Errors + */ +function recordFailedTokenValidation(req) { + const clientIP = getClientIP(req); + const now = Date.now(); + + let requestInfo = requestCounts.get(clientIP); + if (!requestInfo) { + requestInfo = { + count: 0, + resetTime: now + RATE_LIMIT.WINDOW_MS, + failedAttempts: 0 + }; + } + + requestInfo.failedAttempts++; + requestCounts.set(clientIP, requestInfo); + + // Prüfe Brute-Force-Schwelle + if (requestInfo.failedAttempts >= RATE_LIMIT.BRUTE_FORCE_THRESHOLD) { + blockedIPs.set(clientIP, { + reason: 'brute force attack (multiple failed token validations)', + blockedUntil: now + RATE_LIMIT.BLOCK_DURATION_MS, + failedAttempts: requestInfo.failedAttempts + }); + + console.warn(`⚠️ IP ${clientIP} blocked for 24h due to ${requestInfo.failedAttempts} failed token validations`); + + // Reset failed attempts + requestInfo.failedAttempts = 0; + requestCounts.set(clientIP, requestInfo); + } +} + +/** + * Cleanup-Funktion: Entfernt abgelaufene Einträge + * Sollte periodisch aufgerufen werden (z.B. alle 1h) + */ +function cleanupExpiredEntries() { + const now = Date.now(); + let cleaned = 0; + + // Cleanup requestCounts + for (const [ip, info] of requestCounts.entries()) { + if (now > info.resetTime && info.failedAttempts === 0) { + requestCounts.delete(ip); + cleaned++; + } + } + + // Cleanup blockedIPs + for (const [ip, blockInfo] of blockedIPs.entries()) { + if (now > blockInfo.blockedUntil) { + blockedIPs.delete(ip); + cleaned++; + } + } + + if (cleaned > 0) { + console.log(`🧹 Rate-Limiter: Cleaned up ${cleaned} expired entries`); + } +} + +// Auto-Cleanup alle 60 Minuten +setInterval(cleanupExpiredEntries, 60 * 60 * 1000); + +/** + * Statistiken für Monitoring + */ +function getStatistics() { + return { + activeIPs: requestCounts.size, + blockedIPs: blockedIPs.size, + blockedIPsList: Array.from(blockedIPs.entries()).map(([ip, info]) => ({ + ip, + reason: info.reason, + blockedUntil: new Date(info.blockedUntil).toISOString(), + failedAttempts: info.failedAttempts + })) + }; +} + +module.exports = { + rateLimitMiddleware, + recordFailedTokenValidation, + cleanupExpiredEntries, + getStatistics +}; diff --git a/backend/src/routes/management.js b/backend/src/routes/management.js index 5991b5b..9c1b384 100644 --- a/backend/src/routes/management.js +++ b/backend/src/routes/management.js @@ -3,6 +3,10 @@ const router = express.Router(); const groupRepository = require('../repositories/GroupRepository'); const deletionLogRepository = require('../repositories/DeletionLogRepository'); const dbManager = require('../database/DatabaseManager'); +const { rateLimitMiddleware, recordFailedTokenValidation } = require('../middlewares/rateLimiter'); + +// Apply rate limiting to all management routes +router.use(rateLimitMiddleware); // Helper: Validate UUID v4 token format const validateToken = (token) => { @@ -24,6 +28,7 @@ router.get('/:token', async (req, res) => { // Validate token format if (!validateToken(token)) { + recordFailedTokenValidation(req); // Track brute-force attempts return res.status(404).json({ success: false, error: 'Invalid management token format' @@ -34,6 +39,7 @@ router.get('/:token', async (req, res) => { const groupData = await groupRepository.getGroupByManagementToken(token); if (!groupData) { + recordFailedTokenValidation(req); // Track brute-force attempts return res.status(404).json({ success: false, error: 'Management token not found or group has been deleted'