feat: implement dynamic rate limiting with database configuration
Mirror to GitHub / mirror (push) Successful in 21s
Test and Lint / backend-test (push) Successful in 1m10s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m8s
Version and Release / version-bump (push) Successful in 34s
Version and Release / trigger-drone (push) Successful in 3s

- 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 <noreply@anthropic.com>
This commit is contained in:
2025-07-17 08:43:00 +02:00
parent 8cbe97d2f4
commit 9d0607f4f0
5 changed files with 394 additions and 83 deletions
Binary file not shown.
@@ -0,0 +1,63 @@
exports.up = async function(knex) {
// Add rate limit settings to app_settings
const rateLimitSettings = [
{
setting_key: 'rate_limit_enabled',
setting_value: JSON.stringify(true),
setting_type: 'security'
},
{
setting_key: 'rate_limit_window_minutes',
setting_value: JSON.stringify(15),
setting_type: 'security'
},
{
setting_key: 'rate_limit_max_requests',
setting_value: JSON.stringify(1000),
setting_type: 'security'
},
{
setting_key: 'rate_limit_auth_max_requests',
setting_value: JSON.stringify(5),
setting_type: 'security'
},
{
setting_key: 'rate_limit_skip_authenticated',
setting_value: JSON.stringify(true),
setting_type: 'security'
},
{
setting_key: 'rate_limit_public_endpoints_only',
setting_value: JSON.stringify(false),
setting_type: 'security'
}
];
// Insert settings if they don't exist
for (const setting of rateLimitSettings) {
const exists = await knex('app_settings')
.where('setting_key', setting.setting_key)
.first();
if (!exists) {
await knex('app_settings').insert({
...setting,
updated_at: knex.fn.now()
});
}
}
};
exports.down = async function(knex) {
// Remove rate limit settings
await knex('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'
])
.del();
};
+23 -83
View File
@@ -7,8 +7,6 @@ validateEnvironment();
const express = require('express');
const helmet = require('helmet');
const cors = require('cors');
const rateLimit = require('express-rate-limit');
const jwt = require('jsonwebtoken');
const path = require('path');
const { initializeDatabase, db } = require('./src/database/db');
const { startFileWatcher } = require('./src/services/fileWatcher');
@@ -16,6 +14,7 @@ const { startExpirationChecker } = require('./src/services/expirationChecker');
const { initializeTransporter, startEmailQueueProcessor } = require('./src/services/emailProcessor');
const { maintenanceMiddleware } = require('./src/middleware/maintenance');
const { sessionTimeoutMiddleware } = require('./src/middleware/sessionTimeout');
const { createRateLimiter, createAuthRateLimiter } = require('./src/services/rateLimitService');
const logger = require('./src/utils/logger');
// Import routes
@@ -93,86 +92,23 @@ const corsOptions = {
app.use(cors(corsOptions));
// Rate limiting with admin bypass
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: process.env.NODE_ENV === 'development' ? 1000 : 100, // More lenient in development
// Use correct client IP when behind proxy
keyGenerator: (req) => {
// 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();
+65
View File
@@ -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;
+243
View File
@@ -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
};