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
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:
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
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
};
|
||||
Reference in New Issue
Block a user