From 9d0607f4f06127b18f661dde7f7bdc7d7b078df2 Mon Sep 17 00:00:00 2001 From: paul Date: Thu, 17 Jul 2025 08:43:00 +0200 Subject: [PATCH] feat: implement dynamic rate limiting with database configuration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add database migration for rate limit settings - Create rate limit service with dynamic configuration from database - Implement proper authentication detection for admin and gallery tokens - Skip rate limiting for authenticated users (configurable) - Add admin API endpoint to update rate limit settings - Use correct client IP detection with proxy support - Cache settings for performance (1 minute cache) - Default to 1000 requests per 15 minutes for better UX - Apply auth-specific limits only to login endpoints Key improvements: - No more rate limiting for authenticated gallery/admin users - Configurable via admin settings page - Immediate effect when settings change - Better handling of proxied requests 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- backend/data/photo_sharing.db | Bin 208896 -> 208896 bytes .../migrations/027_add_rate_limit_settings.js | 63 +++++ backend/server.js | 106 ++------ backend/src/routes/adminSettings.js | 65 +++++ backend/src/services/rateLimitService.js | 243 ++++++++++++++++++ 5 files changed, 394 insertions(+), 83 deletions(-) create mode 100644 backend/migrations/027_add_rate_limit_settings.js create mode 100644 backend/src/services/rateLimitService.js diff --git a/backend/data/photo_sharing.db b/backend/data/photo_sharing.db index 865d5af538267a5c2557fb52abaccdbe37dc9194..9e37c519e0462543e38cbace36cf06caf4876dcf 100644 GIT binary patch delta 798 zcmZp8z|-)6XM!~2?1?hYjI%c;Nb567Pp;ATVdUI=Qr}vES(@|RWcC14CeC-81qC*6 zN|;KsFnAgp7@5Z>rliCdC6=Vd=Vaz)mP{^;6X7;AS1>TOGPbZXHr}inb-z`Nledk5 z|0aJYKMUV!zCOMnJ|W(7ylp^}a(L_AIN2C{#bu2VX2chiCgo%%$EW6{6y#^-l@!P4 z=jBwUCFT^T7N;hc7G;)H8W|Xw>Kd5qBH84`!N%Y&iegT2c4k3*VrfZ6YF`^T*&&*57FOSd7%quNPE#}A2jnHh)!p2~T>iyKb#H1W-en$2(qw!*v_l#nk zeBT-P-|%1I-@tFjKa)S6?>oN?fAwZXg}Z$9D$Kr&gkpzNk=Y$t5oXYHC@{MstH20X zc4=m7WCgH*vjPP=n=G?EvLtH2u!=DoBFjR3&a!#K|Bwb|X&&zB>2uGhnKX)yqeP6Md` delta 119 zcmV--0Eqv9;0%D^43HZEl#v`m0hF;|8!rJGlYB2s0RywiFFPOu8VV(o2T(P$5fJGK zvn5*Jg$e@?1ON}&4~Y*1vmqe%4x { - // Get the real client IP from proxy headers - const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() || - req.headers['x-real-ip'] || - req.connection.remoteAddress || - req.ip; - - // Log rate limit key for debugging (only in development) - if (process.env.NODE_ENV === 'development' && req.path.includes('/api/')) { - logger.debug('Rate limit key generated', { - path: req.path, - clientIp, - headers: { - 'x-forwarded-for': req.headers['x-forwarded-for'], - 'x-real-ip': req.headers['x-real-ip'] - } - }); - } - - return clientIp; - }, - handler: (req, res) => { - logger.warn('Rate limit exceeded', { - ip: req.headers['x-forwarded-for']?.split(',')[0]?.trim() || req.ip, - path: req.path, - method: req.method - }); - res.status(429).json({ - error: 'Too many requests, please try again later.' - }); - }, - skip: (req) => { - // Skip rate limiting for authenticated admin users - if (req.path.startsWith('/api/admin/') && req.headers.authorization) { - const token = req.headers.authorization.replace('Bearer ', ''); - try { - const decoded = jwt.verify(token, process.env.JWT_SECRET); - return decoded.type === 'admin'; - } catch (err) { - return false; - } - } - // Also skip rate limiting for public settings endpoint in development - if (process.env.NODE_ENV === 'development' && req.path === '/api/public/settings') { - return true; - } - return false; - } -}); +// Initialize rate limiters (they will be created dynamically) +let generalRateLimiter; +let authRateLimiter; -const authLimiter = rateLimit({ - windowMs: 15 * 60 * 1000, - max: 5, // limit auth attempts - // Use correct client IP when behind proxy - keyGenerator: (req) => { - // Get the real client IP from proxy headers - return req.headers['x-forwarded-for']?.split(',')[0]?.trim() || - req.headers['x-real-ip'] || - req.connection.remoteAddress || - req.ip; - }, - handler: (req, res) => { - logger.warn('Auth rate limit exceeded', { - ip: req.headers['x-forwarded-for']?.split(',')[0]?.trim() || req.ip, - path: req.path - }); - res.status(429).json({ - error: 'Too many authentication attempts, please try again later.' - }); - } -}); +// Function to initialize rate limiters +async function initializeRateLimiters() { + generalRateLimiter = await createRateLimiter(); + authRateLimiter = await createAuthRateLimiter(); + + // Apply rate limiting + app.use('/api/', generalRateLimiter); + app.use('/api/auth', authRateLimiter); + app.use('/api/gallery/:slug/verify', authRateLimiter); + app.use('/api/admin/auth/login', authRateLimiter); +} -// Apply rate limiting - admin routes check will skip for valid admin tokens -app.use('/api/', limiter); -app.use('/api/auth', authLimiter); +// Note: Rate limiters will be initialized after database connection // Body parsing middleware with increased limits for large uploads app.use(express.json({ limit: '100mb' })); @@ -274,9 +210,13 @@ async function startServer() { // Initialize database await initializeDatabase(); - // Initialize auth security cleanup job - const { initializeCleanupJob } = require('./src/utils/authSecurity'); - initializeCleanupJob(); + // Initialize rate limiters after database is ready + await initializeRateLimiters(); + logger.info('Rate limiters initialized with database configuration'); + + // Initialize auth security cleanup job + const { initializeCleanupJob } = require('./src/utils/authSecurity'); + initializeCleanupJob(); // Start file watcher startFileWatcher(); diff --git a/backend/src/routes/adminSettings.js b/backend/src/routes/adminSettings.js index dcfe3b8..746283e 100644 --- a/backend/src/routes/adminSettings.js +++ b/backend/src/routes/adminSettings.js @@ -7,6 +7,7 @@ const { db, logActivity } = require('../database/db'); const { formatBoolean } = require('../utils/dbCompat'); const { adminAuth } = require('../middleware/auth'); const { clearMaintenanceCache } = require('../middleware/maintenance'); +const { clearSettingsCache } = require('../services/rateLimitService'); const router = express.Router(); // Configure multer for logo uploads @@ -582,4 +583,68 @@ router.post('/favicon', adminAuth, faviconUpload.single('favicon'), async (req, } }); +// Update rate limit settings +router.put('/security/rate-limit', adminAuth, [ + body('rate_limit_enabled').isBoolean().withMessage('Enabled must be a boolean'), + body('rate_limit_window_minutes').isInt({ min: 1, max: 60 }).withMessage('Window must be between 1 and 60 minutes'), + body('rate_limit_max_requests').isInt({ min: 10, max: 10000 }).withMessage('Max requests must be between 10 and 10000'), + body('rate_limit_auth_max_requests').isInt({ min: 1, max: 100 }).withMessage('Auth max requests must be between 1 and 100'), + body('rate_limit_skip_authenticated').isBoolean().withMessage('Skip authenticated must be a boolean'), + body('rate_limit_public_endpoints_only').isBoolean().withMessage('Public endpoints only must be a boolean') +], async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const { + rate_limit_enabled, + rate_limit_window_minutes, + rate_limit_max_requests, + rate_limit_auth_max_requests, + rate_limit_skip_authenticated, + rate_limit_public_endpoints_only + } = req.body; + + // Update each setting + const settings = [ + { key: 'rate_limit_enabled', value: rate_limit_enabled }, + { key: 'rate_limit_window_minutes', value: rate_limit_window_minutes }, + { key: 'rate_limit_max_requests', value: rate_limit_max_requests }, + { key: 'rate_limit_auth_max_requests', value: rate_limit_auth_max_requests }, + { key: 'rate_limit_skip_authenticated', value: rate_limit_skip_authenticated }, + { key: 'rate_limit_public_endpoints_only', value: rate_limit_public_endpoints_only } + ]; + + for (const { key, value } of settings) { + await db('app_settings') + .where('setting_key', key) + .update({ + setting_value: JSON.stringify(value), + updated_at: new Date() + }); + } + + // Clear the rate limit settings cache to apply changes immediately + clearSettingsCache(); + + // Log activity + await logActivity('settings_updated', + { + category: 'security', + subcategory: 'rate_limit', + changes: settings.length + }, + null, + { type: 'admin', id: req.admin.id, name: req.admin.username } + ); + + res.json({ message: 'Rate limit settings updated successfully' }); + } catch (error) { + console.error('Rate limit settings update error:', error); + res.status(500).json({ error: 'Failed to update rate limit settings' }); + } +}); + module.exports = router; \ No newline at end of file diff --git a/backend/src/services/rateLimitService.js b/backend/src/services/rateLimitService.js new file mode 100644 index 0000000..64c5cc8 --- /dev/null +++ b/backend/src/services/rateLimitService.js @@ -0,0 +1,243 @@ +const rateLimit = require('express-rate-limit'); +const jwt = require('jsonwebtoken'); +const { db } = require('../database/db'); +const logger = require('../utils/logger'); + +// Cache for rate limit settings +let settingsCache = null; +let cacheExpiry = 0; +const CACHE_DURATION = 60000; // 1 minute cache + +/** + * Get rate limit settings from database with caching + */ +async function getRateLimitSettings() { + try { + // Check cache + if (settingsCache && Date.now() < cacheExpiry) { + return settingsCache; + } + + // Fetch from database + const settings = await db('app_settings') + .whereIn('setting_key', [ + 'rate_limit_enabled', + 'rate_limit_window_minutes', + 'rate_limit_max_requests', + 'rate_limit_auth_max_requests', + 'rate_limit_skip_authenticated', + 'rate_limit_public_endpoints_only' + ]); + + // Parse settings into object + const config = { + enabled: true, + windowMinutes: 15, + maxRequests: 100, + authMaxRequests: 5, + skipAuthenticated: true, + publicEndpointsOnly: false + }; + + settings.forEach(setting => { + const value = JSON.parse(setting.setting_value); + switch (setting.setting_key) { + case 'rate_limit_enabled': + config.enabled = value; + break; + case 'rate_limit_window_minutes': + config.windowMinutes = value; + break; + case 'rate_limit_max_requests': + config.maxRequests = value; + break; + case 'rate_limit_auth_max_requests': + config.authMaxRequests = value; + break; + case 'rate_limit_skip_authenticated': + config.skipAuthenticated = value; + break; + case 'rate_limit_public_endpoints_only': + config.publicEndpointsOnly = value; + break; + } + }); + + // Update cache + settingsCache = config; + cacheExpiry = Date.now() + CACHE_DURATION; + + return config; + } catch (error) { + logger.error('Failed to fetch rate limit settings:', error); + // Return defaults on error + return { + enabled: true, + windowMinutes: 15, + maxRequests: 100, + authMaxRequests: 5, + skipAuthenticated: true, + publicEndpointsOnly: false + }; + } +} + +/** + * Clear settings cache (call when settings are updated) + */ +function clearSettingsCache() { + settingsCache = null; + cacheExpiry = 0; +} + +/** + * Check if request has valid authentication + */ +function isAuthenticated(req) { + try { + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return false; + } + + const token = authHeader.substring(7); + const decoded = jwt.verify(token, process.env.JWT_SECRET); + + // Check if token is valid + if (!decoded || typeof decoded !== 'object') { + return false; + } + + // Valid token found - check type + req.tokenType = decoded.type; // 'admin' or 'gallery' + req.tokenPayload = decoded; + + return true; + } catch (error) { + return false; + } +} + +/** + * Determine if rate limiting should be applied to this request + */ +function shouldSkipRateLimit(req, config) { + // If rate limiting is disabled globally + if (!config.enabled) { + return true; + } + + // Never skip rate limiting for auth endpoints + const isAuthEndpoint = req.path.match(/\/(auth|login|gallery\/[^/]+\/verify)$/); + if (isAuthEndpoint) { + return false; + } + + // Check if we should skip authenticated requests + if (config.skipAuthenticated && isAuthenticated(req)) { + return true; + } + + // Check if we only rate limit public endpoints + if (config.publicEndpointsOnly) { + const isPublicEndpoint = req.path.startsWith('/api/public/') || + req.path.startsWith('/api/gallery/') || + isAuthEndpoint; + return !isPublicEndpoint; + } + + return false; +} + +/** + * Create dynamic rate limiter + */ +async function createRateLimiter() { + const config = await getRateLimitSettings(); + + return rateLimit({ + windowMs: config.windowMinutes * 60 * 1000, + max: async (req) => { + // Refresh config for each request + const currentConfig = await getRateLimitSettings(); + + // Different limits for auth endpoints + const isAuthEndpoint = req.path.match(/\/(auth|login|gallery\/[^/]+\/verify)$/); + return isAuthEndpoint ? currentConfig.authMaxRequests : currentConfig.maxRequests; + }, + keyGenerator: (req) => { + // Use correct client IP when behind proxy + return req.headers['x-forwarded-for']?.split(',')[0]?.trim() || + req.headers['x-real-ip'] || + req.connection.remoteAddress || + req.ip; + }, + skip: async (req) => { + const currentConfig = await getRateLimitSettings(); + return shouldSkipRateLimit(req, currentConfig); + }, + handler: (req, res) => { + const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() || req.ip; + logger.warn('Rate limit exceeded', { + ip: clientIp, + path: req.path, + method: req.method, + authenticated: isAuthenticated(req), + tokenType: req.tokenType + }); + + res.status(429).json({ + error: 'Too many requests, please try again later.', + retryAfter: res.getHeader('Retry-After') + }); + }, + standardHeaders: true, // Return rate limit info in headers + legacyHeaders: false, // Disable X-RateLimit headers + }); +} + +/** + * Create auth-specific rate limiter + */ +async function createAuthRateLimiter() { + const config = await getRateLimitSettings(); + + return rateLimit({ + windowMs: config.windowMinutes * 60 * 1000, + max: config.authMaxRequests, + keyGenerator: (req) => { + // Use correct client IP when behind proxy + return req.headers['x-forwarded-for']?.split(',')[0]?.trim() || + req.headers['x-real-ip'] || + req.connection.remoteAddress || + req.ip; + }, + skip: async () => { + const currentConfig = await getRateLimitSettings(); + return !currentConfig.enabled; + }, + handler: (req, res) => { + const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() || req.ip; + logger.warn('Auth rate limit exceeded', { + ip: clientIp, + path: req.path + }); + + res.status(429).json({ + error: 'Too many authentication attempts, please try again later.', + retryAfter: res.getHeader('Retry-After') + }); + }, + standardHeaders: true, + legacyHeaders: false, + }); +} + +module.exports = { + getRateLimitSettings, + clearSettingsCache, + createRateLimiter, + createAuthRateLimiter, + isAuthenticated, + shouldSkipRateLimit +}; \ No newline at end of file