feat(phase2): Implement Rate-Limiting & Brute-Force Protection (Task 9)

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
This commit is contained in:
Matthias Lotz 2025-11-11 19:59:41 +01:00
parent 2d49f0b826
commit 0dce5fddac
2 changed files with 187 additions and 0 deletions

View File

@ -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
};

View File

@ -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'