Skip to content

Commit 834040e

Browse files
authored
Merge pull request #87 from CleanDev-Fix/cleandev-fix/rate-limit-public-endpoints-56
Add public API rate limit tiers
2 parents 7d1a1a9 + 480c64e commit 834040e

8 files changed

Lines changed: 242 additions & 90 deletions

File tree

backend/src/index.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,10 @@ const quizRoutes = resolveRoute(require('./routes/quizRoutes'));
4444
const eventLoggerRoutes = resolveRoute(require('./routes/eventLoggerRoutes'));
4545
const syncRoutes = resolveRoute(require('./routes/syncRoutes'));
4646
const rbacRoutes = resolveRoute(require('./routes/rbacRoutes'));
47+
const authRoutes = require('./routes/auth');
4748
const contentRoutes = require('./routes/content');
49+
const courseRoutes = require('./routes/courses');
50+
const searchRoutes = require('./routes/search');
4851
const transactionRoutes = require('./routes/transactions');
4952
const notificationRoutes = resolveRoute(require('./routes/notificationRoutes'));
5053

@@ -113,7 +116,10 @@ const v1Router = createVersionedRouter('v1');
113116
v1Router.use('/quizzes', quizRoutes);
114117
v1Router.use('/events', eventLoggerRoutes);
115118
v1Router.use('/sync', syncRoutes);
119+
v1Router.use('/auth', authRoutes);
116120
v1Router.use('/content', contentRoutes);
121+
v1Router.use('/courses', courseRoutes);
122+
v1Router.use('/search', searchRoutes);
117123
v1Router.use('/rbac', rbacRoutes);
118124
v1Router.use('/transactions', transactionRoutes);
119125
v1Router.use('/notifications', notificationRoutes);

backend/src/middleware/rateLimit.ts

Lines changed: 32 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55

66
import rateLimit from 'express-rate-limit';
77

8+
const ONE_MINUTE = 60 * 1000;
9+
810
export interface RateLimitOptions {
911
windowMs: number; // Time window in milliseconds
1012
max: number; // Max requests per window
@@ -15,13 +17,31 @@ export interface RateLimitOptions {
1517
skipFailedRequests?: boolean;
1618
}
1719

20+
export const publicRateLimitTiers = {
21+
strict: {
22+
windowMs: ONE_MINUTE,
23+
max: 5,
24+
message: 'Too many authentication attempts, please try again after a minute',
25+
},
26+
moderate: {
27+
windowMs: ONE_MINUTE,
28+
max: 30,
29+
message: 'Too many content write requests, please try again after a minute',
30+
},
31+
liberal: {
32+
windowMs: ONE_MINUTE,
33+
max: 100,
34+
message: 'Too many read requests, please try again after a minute',
35+
},
36+
} as const;
37+
1838
export const rateLimitMiddleware = (options: RateLimitOptions) => {
1939
return rateLimit({
2040
windowMs: options.windowMs,
2141
max: options.max,
2242
message: options.message || 'Too many requests from this IP, please try again later.',
2343
standardHeaders: options.standardHeaders !== false, // Send rate limit info in headers
24-
legacyHeaders: options.legacyHeaders !== false, // Send rate limit info in legacy headers
44+
legacyHeaders: options.legacyHeaders !== false, // Send X-RateLimit-* headers
2545
skipSuccessfulRequests: options.skipSuccessfulRequests || false,
2646
skipFailedRequests: options.skipFailedRequests || false,
2747
handler: (req, res) => {
@@ -35,31 +55,25 @@ export const rateLimitMiddleware = (options: RateLimitOptions) => {
3555

3656
// Predefined rate limit configurations
3757
export const rateLimits = {
38-
// Very strict limits for sensitive operations
39-
auth: rateLimitMiddleware({
40-
windowMs: 15 * 60 * 1000, // 15 minutes
41-
max: 5, // 5 attempts per 15 minutes
42-
message: 'Too many authentication attempts, please try again later.'
43-
}),
58+
strict: rateLimitMiddleware(publicRateLimitTiers.strict),
59+
60+
moderate: rateLimitMiddleware(publicRateLimitTiers.moderate),
61+
62+
liberal: rateLimitMiddleware(publicRateLimitTiers.liberal),
63+
64+
auth: rateLimitMiddleware(publicRateLimitTiers.strict),
4465

4566
// Strict limits for file uploads
4667
upload: rateLimitMiddleware({
47-
windowMs: 15 * 60 * 1000, // 15 minutes
48-
max: 20, // 20 uploads per 15 minutes
49-
message: 'Too many file uploads, please try again later.'
68+
...publicRateLimitTiers.moderate,
69+
message: 'Too many file uploads, please try again after a minute.'
5070
}),
5171

5272
// Moderate limits for general API usage
53-
general: rateLimitMiddleware({
54-
windowMs: 15 * 60 * 1000, // 15 minutes
55-
max: 100, // 100 requests per 15 minutes
56-
}),
73+
general: rateLimitMiddleware(publicRateLimitTiers.moderate),
5774

5875
// Lenient limits for read-only operations
59-
readOnly: rateLimitMiddleware({
60-
windowMs: 15 * 60 * 1000, // 15 minutes
61-
max: 200, // 200 requests per 15 minutes
62-
}),
76+
readOnly: rateLimitMiddleware(publicRateLimitTiers.liberal),
6377

6478
// Very lenient limits for static content
6579
static: rateLimitMiddleware({

backend/src/middleware/rateLimiter.js

Lines changed: 68 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,32 @@ const redisConfig = require('../config/redis');
33
const securityConfig = require('../config/security');
44
const logger = require('../utils/logger');
55

6+
const ONE_MINUTE = 60 * 1000;
7+
8+
const publicRateLimitTiers = {
9+
strict: {
10+
windowMs: ONE_MINUTE,
11+
max: 5,
12+
message: 'Too many authentication attempts, please try again after a minute',
13+
keyPrefix: 'rl:public:strict:',
14+
keyByUser: false,
15+
},
16+
moderate: {
17+
windowMs: ONE_MINUTE,
18+
max: 30,
19+
message: 'Too many content write requests, please try again after a minute',
20+
keyPrefix: 'rl:public:moderate:',
21+
keyByUser: true,
22+
},
23+
liberal: {
24+
windowMs: ONE_MINUTE,
25+
max: 100,
26+
message: 'Too many read requests, please try again after a minute',
27+
keyPrefix: 'rl:public:liberal:',
28+
keyByUser: false,
29+
},
30+
};
31+
632
/**
733
* Custom Simple Redis Store for express-rate-limit
834
*/
@@ -21,12 +47,11 @@ class RedisStore {
2147
multi.expire(fullKey, this.expiry);
2248
const results = await multi.exec();
2349

24-
const currentCount = results[0];
25-
const ttl = results[1];
50+
const currentCount = Array.isArray(results[0]) ? results[0][1] : results[0];
2651

2752
return {
2853
totalHits: currentCount,
29-
resetTime: new Date(Date.now() + (ttl * 1000 || this.expiry * 1000))
54+
resetTime: new Date(Date.now() + (this.expiry * 1000))
3055
};
3156
} catch (error) {
3257
logger.error(`RedisStore error for key ${fullKey}:`, error);
@@ -63,6 +88,7 @@ const createRateLimiter = (options = {}) => {
6388
max = securityConfig.tiers.default.max,
6489
message = securityConfig.tiers.default.message,
6590
keyPrefix = 'rl:',
91+
keyByUser = true,
6692
} = options;
6793

6894
const securityService = require('../services/securityService');
@@ -75,7 +101,7 @@ const createRateLimiter = (options = {}) => {
75101
message,
76102
},
77103
standardHeaders: true,
78-
legacyHeaders: false,
104+
legacyHeaders: true,
79105
store: new RedisStore({
80106
prefix: keyPrefix,
81107
expiry: Math.ceil(windowMs / 1000),
@@ -94,11 +120,10 @@ const createRateLimiter = (options = {}) => {
94120
res.status(options.statusCode).send(options.message);
95121
},
96122
keyGenerator: (req) => {
97-
// Use user ID if authenticated, else fallback to IP
98-
if (req.user && (req.user.id || req.user.sub)) {
99-
return `${keyPrefix}${req.user.id || req.user.sub}`;
123+
if (keyByUser && req.user && (req.user.id || req.user.sub || req.user.userId)) {
124+
return `user:${req.user.id || req.user.sub || req.user.userId}`;
100125
}
101-
return req.ip;
126+
return `ip:${req.ip}`;
102127
},
103128
skip: (req) => {
104129
// Skip whitelisted IPs or in test environment (unless explicitly testing security)
@@ -141,9 +166,7 @@ const adminLimiter = createRateLimiter({
141166

142167
// Endpoint-specific limiters
143168
const authLimiter = createRateLimiter({
144-
windowMs: securityConfig.endpoints.auth.windowMs,
145-
max: securityConfig.endpoints.auth.max,
146-
message: securityConfig.endpoints.auth.message,
169+
...publicRateLimitTiers.strict,
147170
keyPrefix: 'rl:auth:',
148171
});
149172

@@ -161,6 +184,32 @@ const ipfsLimiter = createRateLimiter({
161184
keyPrefix: 'rl:ipfs:',
162185
});
163186

187+
const strictLimiter = createRateLimiter(publicRateLimitTiers.strict);
188+
189+
const moderateLimiter = createRateLimiter(publicRateLimitTiers.moderate);
190+
191+
const liberalLimiter = createRateLimiter(publicRateLimitTiers.liberal);
192+
193+
const contentWriteLimiter = createRateLimiter({
194+
...publicRateLimitTiers.moderate,
195+
keyPrefix: 'rl:content:write:',
196+
});
197+
198+
const readLimiter = createRateLimiter({
199+
...publicRateLimitTiers.liberal,
200+
keyPrefix: 'rl:public:read:',
201+
});
202+
203+
const searchWriteLimiter = createRateLimiter({
204+
...publicRateLimitTiers.moderate,
205+
keyPrefix: 'rl:search:write:',
206+
});
207+
208+
const courseWriteLimiter = createRateLimiter({
209+
...publicRateLimitTiers.moderate,
210+
keyPrefix: 'rl:courses:write:',
211+
});
212+
164213
/**
165214
* Middleware to select rate limiter based on user role
166215
*/
@@ -189,5 +238,13 @@ module.exports = {
189238
authLimiter,
190239
transactionLimiter,
191240
ipfsLimiter,
241+
strictLimiter,
242+
moderateLimiter,
243+
liberalLimiter,
244+
contentWriteLimiter,
245+
readLimiter,
246+
searchWriteLimiter,
247+
courseWriteLimiter,
248+
publicRateLimitTiers,
192249
createRateLimiter
193250
};

backend/src/routes/auth.js

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ const jwt = require('jsonwebtoken');
33
const bcrypt = require('bcryptjs');
44
const { authenticateToken, requireAdmin, requirePermission } = require('../middleware/auth');
55
const { UserRole, PERMISSIONS } = require('../utils/roles');
6-
const { authLimiter } = require('../middleware/rateLimiter');
6+
const { authLimiter, readLimiter, moderateLimiter } = require('../middleware/rateLimiter');
77
const securityService = require('../services/securityService');
88
const router = express.Router();
99

@@ -169,7 +169,7 @@ router.post('/login', authLimiter, async (req, res) => {
169169
* Get current user profile
170170
* GET /api/auth/profile
171171
*/
172-
router.get('/profile', authenticateToken, (req, res) => {
172+
router.get('/profile', readLimiter, authenticateToken, (req, res) => {
173173
const user = users.get(req.user.id);
174174

175175
if (!user) {
@@ -195,7 +195,7 @@ router.get('/profile', authenticateToken, (req, res) => {
195195
* Update user profile
196196
* PUT /api/auth/profile
197197
*/
198-
router.put('/profile', authenticateToken, async (req, res) => {
198+
router.put('/profile', moderateLimiter, authenticateToken, async (req, res) => {
199199
try {
200200
const { username, email, currentPassword, newPassword } = req.body;
201201
const user = users.get(req.user.id);
@@ -286,6 +286,7 @@ router.put('/profile', authenticateToken, async (req, res) => {
286286
* PUT /api/auth/assign-role/:userId
287287
*/
288288
router.put('/assign-role/:userId',
289+
moderateLimiter,
289290
authenticateToken,
290291
requireAdmin,
291292
requirePermission(PERMISSIONS.USER_ASSIGN_ROLE),
@@ -341,6 +342,7 @@ router.put('/assign-role/:userId',
341342
* GET /api/auth/users
342343
*/
343344
router.get('/users',
345+
readLimiter,
344346
authenticateToken,
345347
requireAdmin,
346348
requirePermission(PERMISSIONS.USER_READ),
@@ -393,6 +395,7 @@ router.get('/users',
393395
* DELETE /api/auth/users/:userId
394396
*/
395397
router.delete('/users/:userId',
398+
moderateLimiter,
396399
authenticateToken,
397400
requireAdmin,
398401
requirePermission(PERMISSIONS.USER_DELETE),

backend/src/routes/content.js

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ const { requirePermission } = require('../middleware/rbac');
66
const { PERMISSIONS } = require('../utils/roles');
77
const { ipfsAuth, optionalIpfsAuth, validateContentAccess, validateFileSize } = require('../middleware/ipfsAuth');
88
const { createIpfsError } = require('../utils/ipfsUtils');
9-
const { ipfsLimiter } = require('../middleware/rateLimiter');
9+
const { contentWriteLimiter, readLimiter } = require('../middleware/rateLimiter');
1010

1111
// Configure multer for file uploads
1212
const storage = multer.memoryStorage();
@@ -27,8 +27,8 @@ const upload = multer({
2727
* POST /api/content/upload
2828
*/
2929
router.post('/upload',
30+
contentWriteLimiter,
3031
requirePermission(PERMISSIONS.CONTENT_CREATE),
31-
ipfsLimiter,
3232
ipfsAuth('upload'),
3333
upload.single('file'),
3434
validateFileSize,
@@ -90,8 +90,8 @@ router.post('/upload',
9090
* POST /api/content/upload/batch
9191
*/
9292
router.post('/upload/batch',
93+
contentWriteLimiter,
9394
requirePermission(PERMISSIONS.CONTENT_CREATE),
94-
ipfsLimiter,
9595
ipfsAuth('upload'),
9696
upload.array('files', 10),
9797
validateFileSize,
@@ -158,6 +158,7 @@ router.post('/upload/batch',
158158
* GET /api/content/:cid
159159
*/
160160
router.get('/:cid',
161+
readLimiter,
161162
requirePermission(PERMISSIONS.CONTENT_READ),
162163
validateContentAccess,
163164
async (req, res) => {
@@ -215,6 +216,7 @@ router.get('/:cid',
215216
* GET /api/content/:cid/metadata
216217
*/
217218
router.get('/:cid/metadata',
219+
readLimiter,
218220
requirePermission(PERMISSIONS.CONTENT_READ),
219221
validateContentAccess,
220222
async (req, res) => {
@@ -262,6 +264,7 @@ router.get('/:cid/metadata',
262264
* POST /api/content/:cid/pin
263265
*/
264266
router.post('/:cid/pin',
267+
contentWriteLimiter,
265268
requirePermission(PERMISSIONS.COURSE_UPDATE), // Pinning requires course update permission
266269
validateContentAccess,
267270
async (req, res) => {
@@ -301,6 +304,7 @@ router.post('/:cid/pin',
301304
* DELETE /api/content/:cid/pin
302305
*/
303306
router.delete('/:cid/pin',
307+
contentWriteLimiter,
304308
requirePermission(PERMISSIONS.COURSE_UPDATE),
305309
validateContentAccess,
306310
async (req, res) => {
@@ -340,6 +344,7 @@ router.delete('/:cid/pin',
340344
* GET /api/content/node/info
341345
*/
342346
router.get('/node/info',
347+
readLimiter,
343348
requirePermission(PERMISSIONS.SYSTEM_MANAGE),
344349
async (req, res) => {
345350
try {
@@ -375,6 +380,7 @@ router.get('/node/info',
375380
* GET /api/content/cache/stats
376381
*/
377382
router.get('/cache/stats',
383+
readLimiter,
378384
requirePermission(PERMISSIONS.ANALYTICS_READ),
379385
async (req, res) => {
380386
try {
@@ -401,6 +407,7 @@ router.get('/cache/stats',
401407
* DELETE /api/content/cache
402408
*/
403409
router.delete('/cache',
410+
contentWriteLimiter,
404411
requirePermission(PERMISSIONS.SYSTEM_MANAGE),
405412
async (req, res) => {
406413
try {
@@ -427,6 +434,7 @@ router.delete('/cache',
427434
* GET /api/content/health
428435
*/
429436
router.get('/health',
437+
readLimiter,
430438
async (req, res) => {
431439
try {
432440
const nodeInfo = await ipfsService.getNodeInfo();

0 commit comments

Comments
 (0)