mastercontroller 1.3.10 → 1.3.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,462 @@
1
+ /**
2
+ * RedisRateLimiter - Distributed rate limiting for horizontal scaling
3
+ * Version: 1.0.0
4
+ *
5
+ * Implements rate limiting across multiple MasterController instances using Redis.
6
+ * Essential for Fortune 500 load-balanced deployments to prevent API abuse.
7
+ *
8
+ * Installation:
9
+ * npm install ioredis --save
10
+ *
11
+ * Usage:
12
+ *
13
+ * const Redis = require('ioredis');
14
+ * const { RedisRateLimiter } = require('./security/adapters/RedisRateLimiter');
15
+ *
16
+ * const redis = new Redis({ host: 'localhost', port: 6379 });
17
+ *
18
+ * const rateLimiter = new RedisRateLimiter(redis, {
19
+ * points: 100, // Number of requests
20
+ * duration: 60, // Per 60 seconds
21
+ * blockDuration: 300 // Block for 5 minutes on exceed
22
+ * });
23
+ *
24
+ * // In MasterPipeline middleware:
25
+ * const allowed = await rateLimiter.consume(ctx.request.connection.remoteAddress);
26
+ * if (!allowed) {
27
+ * ctx.response.statusCode = 429;
28
+ * ctx.response.end('Too Many Requests');
29
+ * return;
30
+ * }
31
+ *
32
+ * Features:
33
+ * - Token bucket algorithm with Redis
34
+ * - Distributed rate limiting across instances
35
+ * - Per-IP, per-user, or custom key limiting
36
+ * - Automatic cleanup of expired keys
37
+ * - Configurable block duration on limit exceed
38
+ */
39
+
40
+ const { logger } = require('../../error/MasterErrorLogger');
41
+
42
+ class RedisRateLimiter {
43
+ constructor(redisClient, options = {}) {
44
+ if (!redisClient) {
45
+ throw new Error('RedisRateLimiter requires a Redis client (ioredis)');
46
+ }
47
+
48
+ this.redis = redisClient;
49
+ this.options = {
50
+ prefix: options.prefix || 'mastercontroller:ratelimit:',
51
+ points: options.points || 100, // Max requests
52
+ duration: options.duration || 60, // Time window in seconds
53
+ blockDuration: options.blockDuration || 0, // Block duration on exceed (0 = no block)
54
+ execEvenly: options.execEvenly || false, // Spread requests evenly over duration
55
+ ...options
56
+ };
57
+
58
+ logger.info({
59
+ code: 'MC_RATELIMIT_REDIS_INIT',
60
+ message: 'Redis rate limiter initialized',
61
+ points: this.options.points,
62
+ duration: this.options.duration
63
+ });
64
+ }
65
+
66
+ /**
67
+ * Generate Redis key for rate limit
68
+ */
69
+ _getKey(identifier) {
70
+ return `${this.options.prefix}${identifier}`;
71
+ }
72
+
73
+ /**
74
+ * Generate block key
75
+ */
76
+ _getBlockKey(identifier) {
77
+ return `${this.options.prefix}block:${identifier}`;
78
+ }
79
+
80
+ /**
81
+ * Consume points (check if request is allowed)
82
+ * Returns object with: { allowed, remaining, resetAt }
83
+ */
84
+ async consume(identifier, points = 1) {
85
+ try {
86
+ const key = this._getKey(identifier);
87
+ const blockKey = this._getBlockKey(identifier);
88
+ const now = Date.now();
89
+
90
+ // Check if identifier is blocked
91
+ const blockExpiry = await this.redis.get(blockKey);
92
+ if (blockExpiry && parseInt(blockExpiry) > now) {
93
+ logger.debug({
94
+ code: 'MC_RATELIMIT_BLOCKED',
95
+ message: 'Request blocked due to rate limit',
96
+ identifier: identifier,
97
+ blockedUntil: new Date(parseInt(blockExpiry)).toISOString()
98
+ });
99
+
100
+ return {
101
+ allowed: false,
102
+ remaining: 0,
103
+ resetAt: parseInt(blockExpiry),
104
+ blocked: true
105
+ };
106
+ }
107
+
108
+ // Use Lua script for atomic rate limiting
109
+ const script = `
110
+ local key = KEYS[1]
111
+ local points = tonumber(ARGV[1])
112
+ local duration = tonumber(ARGV[2])
113
+ local max_points = tonumber(ARGV[3])
114
+ local now = tonumber(ARGV[4])
115
+
116
+ -- Get current counter
117
+ local current = redis.call('GET', key)
118
+ local ttl = redis.call('TTL', key)
119
+
120
+ if current == false then
121
+ -- First request, initialize counter
122
+ redis.call('SETEX', key, duration, points)
123
+ return {max_points - points, now + (duration * 1000)}
124
+ else
125
+ current = tonumber(current)
126
+
127
+ if current + points <= max_points then
128
+ -- Allow request
129
+ redis.call('INCRBY', key, points)
130
+ local remaining = max_points - (current + points)
131
+ local reset_at = now + (ttl * 1000)
132
+ return {remaining, reset_at}
133
+ else
134
+ -- Deny request (over limit)
135
+ local reset_at = now + (ttl * 1000)
136
+ return {0, reset_at}
137
+ end
138
+ end
139
+ `;
140
+
141
+ const result = await this.redis.eval(
142
+ script,
143
+ 1,
144
+ key,
145
+ points,
146
+ this.options.duration,
147
+ this.options.points,
148
+ now
149
+ );
150
+
151
+ const remaining = result[0];
152
+ const resetAt = result[1];
153
+ const allowed = remaining >= 0;
154
+
155
+ if (!allowed) {
156
+ // Over limit - create block if configured
157
+ if (this.options.blockDuration > 0) {
158
+ const blockUntil = now + (this.options.blockDuration * 1000);
159
+ await this.redis.setex(
160
+ blockKey,
161
+ this.options.blockDuration,
162
+ blockUntil.toString()
163
+ );
164
+
165
+ logger.warn({
166
+ code: 'MC_RATELIMIT_EXCEEDED_BLOCKED',
167
+ message: 'Rate limit exceeded, identifier blocked',
168
+ identifier: identifier,
169
+ blockDuration: this.options.blockDuration,
170
+ blockedUntil: new Date(blockUntil).toISOString()
171
+ });
172
+ } else {
173
+ logger.warn({
174
+ code: 'MC_RATELIMIT_EXCEEDED',
175
+ message: 'Rate limit exceeded',
176
+ identifier: identifier
177
+ });
178
+ }
179
+ }
180
+
181
+ return {
182
+ allowed: allowed,
183
+ remaining: Math.max(0, remaining),
184
+ resetAt: resetAt,
185
+ blocked: false
186
+ };
187
+
188
+ } catch (error) {
189
+ logger.error({
190
+ code: 'MC_RATELIMIT_ERROR',
191
+ message: 'Rate limit check failed',
192
+ identifier: identifier,
193
+ error: error.message
194
+ });
195
+
196
+ // On error, allow request (fail open for availability)
197
+ return {
198
+ allowed: true,
199
+ remaining: this.options.points,
200
+ resetAt: Date.now() + (this.options.duration * 1000),
201
+ error: true
202
+ };
203
+ }
204
+ }
205
+
206
+ /**
207
+ * Consume multiple points at once
208
+ */
209
+ async consumePoints(identifier, points) {
210
+ return await this.consume(identifier, points);
211
+ }
212
+
213
+ /**
214
+ * Get current rate limit status without consuming
215
+ */
216
+ async get(identifier) {
217
+ try {
218
+ const key = this._getKey(identifier);
219
+ const blockKey = this._getBlockKey(identifier);
220
+ const now = Date.now();
221
+
222
+ // Check if blocked
223
+ const blockExpiry = await this.redis.get(blockKey);
224
+ if (blockExpiry && parseInt(blockExpiry) > now) {
225
+ return {
226
+ consumed: this.options.points,
227
+ remaining: 0,
228
+ resetAt: parseInt(blockExpiry),
229
+ blocked: true
230
+ };
231
+ }
232
+
233
+ // Get current consumption
234
+ const consumed = await this.redis.get(key);
235
+ const ttl = await this.redis.ttl(key);
236
+
237
+ if (!consumed) {
238
+ return {
239
+ consumed: 0,
240
+ remaining: this.options.points,
241
+ resetAt: now + (this.options.duration * 1000),
242
+ blocked: false
243
+ };
244
+ }
245
+
246
+ const remaining = Math.max(0, this.options.points - parseInt(consumed));
247
+ const resetAt = now + (ttl * 1000);
248
+
249
+ return {
250
+ consumed: parseInt(consumed),
251
+ remaining: remaining,
252
+ resetAt: resetAt,
253
+ blocked: false
254
+ };
255
+
256
+ } catch (error) {
257
+ logger.error({
258
+ code: 'MC_RATELIMIT_GET_ERROR',
259
+ message: 'Failed to get rate limit status',
260
+ identifier: identifier,
261
+ error: error.message
262
+ });
263
+
264
+ return {
265
+ consumed: 0,
266
+ remaining: this.options.points,
267
+ resetAt: Date.now() + (this.options.duration * 1000),
268
+ error: true
269
+ };
270
+ }
271
+ }
272
+
273
+ /**
274
+ * Reset rate limit for identifier
275
+ */
276
+ async reset(identifier) {
277
+ try {
278
+ const key = this._getKey(identifier);
279
+ const blockKey = this._getBlockKey(identifier);
280
+
281
+ await this.redis.del(key);
282
+ await this.redis.del(blockKey);
283
+
284
+ logger.debug({
285
+ code: 'MC_RATELIMIT_RESET',
286
+ message: 'Rate limit reset',
287
+ identifier: identifier
288
+ });
289
+
290
+ return true;
291
+
292
+ } catch (error) {
293
+ logger.error({
294
+ code: 'MC_RATELIMIT_RESET_ERROR',
295
+ message: 'Failed to reset rate limit',
296
+ identifier: identifier,
297
+ error: error.message
298
+ });
299
+ return false;
300
+ }
301
+ }
302
+
303
+ /**
304
+ * Block identifier for specified duration (seconds)
305
+ */
306
+ async block(identifier, duration = null) {
307
+ try {
308
+ const blockKey = this._getBlockKey(identifier);
309
+ const blockDuration = duration || this.options.blockDuration;
310
+ const blockUntil = Date.now() + (blockDuration * 1000);
311
+
312
+ await this.redis.setex(
313
+ blockKey,
314
+ blockDuration,
315
+ blockUntil.toString()
316
+ );
317
+
318
+ logger.info({
319
+ code: 'MC_RATELIMIT_MANUAL_BLOCK',
320
+ message: 'Identifier manually blocked',
321
+ identifier: identifier,
322
+ duration: blockDuration,
323
+ blockedUntil: new Date(blockUntil).toISOString()
324
+ });
325
+
326
+ return true;
327
+
328
+ } catch (error) {
329
+ logger.error({
330
+ code: 'MC_RATELIMIT_BLOCK_ERROR',
331
+ message: 'Failed to block identifier',
332
+ identifier: identifier,
333
+ error: error.message
334
+ });
335
+ return false;
336
+ }
337
+ }
338
+
339
+ /**
340
+ * Unblock identifier
341
+ */
342
+ async unblock(identifier) {
343
+ try {
344
+ const blockKey = this._getBlockKey(identifier);
345
+ await this.redis.del(blockKey);
346
+
347
+ logger.info({
348
+ code: 'MC_RATELIMIT_UNBLOCK',
349
+ message: 'Identifier unblocked',
350
+ identifier: identifier
351
+ });
352
+
353
+ return true;
354
+
355
+ } catch (error) {
356
+ logger.error({
357
+ code: 'MC_RATELIMIT_UNBLOCK_ERROR',
358
+ message: 'Failed to unblock identifier',
359
+ identifier: identifier,
360
+ error: error.message
361
+ });
362
+ return false;
363
+ }
364
+ }
365
+
366
+ /**
367
+ * Check if identifier is blocked
368
+ */
369
+ async isBlocked(identifier) {
370
+ try {
371
+ const blockKey = this._getBlockKey(identifier);
372
+ const blockExpiry = await this.redis.get(blockKey);
373
+
374
+ if (blockExpiry && parseInt(blockExpiry) > Date.now()) {
375
+ return {
376
+ blocked: true,
377
+ blockedUntil: parseInt(blockExpiry)
378
+ };
379
+ }
380
+
381
+ return {
382
+ blocked: false,
383
+ blockedUntil: null
384
+ };
385
+
386
+ } catch (error) {
387
+ logger.error({
388
+ code: 'MC_RATELIMIT_IS_BLOCKED_ERROR',
389
+ message: 'Failed to check block status',
390
+ identifier: identifier,
391
+ error: error.message
392
+ });
393
+ return {
394
+ blocked: false,
395
+ blockedUntil: null,
396
+ error: true
397
+ };
398
+ }
399
+ }
400
+
401
+ /**
402
+ * Middleware factory for MasterPipeline
403
+ */
404
+ middleware(options = {}) {
405
+ const self = this;
406
+ const keyGenerator = options.keyGenerator || ((ctx) => {
407
+ // Default: use IP address
408
+ return ctx.request.connection.remoteAddress || 'unknown';
409
+ });
410
+
411
+ return async (ctx, next) => {
412
+ try {
413
+ const identifier = keyGenerator(ctx);
414
+ const result = await self.consume(identifier);
415
+
416
+ // Add rate limit headers to response
417
+ ctx.response.setHeader('X-RateLimit-Limit', self.options.points);
418
+ ctx.response.setHeader('X-RateLimit-Remaining', result.remaining);
419
+ ctx.response.setHeader('X-RateLimit-Reset', new Date(result.resetAt).toISOString());
420
+
421
+ if (!result.allowed) {
422
+ // Rate limit exceeded
423
+ ctx.response.statusCode = 429;
424
+ ctx.response.setHeader('Content-Type', 'application/json');
425
+ ctx.response.setHeader('Retry-After', Math.ceil((result.resetAt - Date.now()) / 1000));
426
+
427
+ const errorResponse = {
428
+ error: 'Too Many Requests',
429
+ message: 'Rate limit exceeded',
430
+ limit: self.options.points,
431
+ resetAt: new Date(result.resetAt).toISOString()
432
+ };
433
+
434
+ if (result.blocked) {
435
+ errorResponse.blocked = true;
436
+ errorResponse.message = 'Rate limit exceeded. Temporarily blocked.';
437
+ }
438
+
439
+ ctx.response.end(JSON.stringify(errorResponse, null, 2));
440
+ return; // Don't call next()
441
+ }
442
+
443
+ // Request allowed, continue pipeline
444
+ await next();
445
+
446
+ } catch (error) {
447
+ logger.error({
448
+ code: 'MC_RATELIMIT_MIDDLEWARE_ERROR',
449
+ message: 'Rate limit middleware error',
450
+ error: error.message
451
+ });
452
+
453
+ // On error, allow request (fail open)
454
+ await next();
455
+ }
456
+ };
457
+ }
458
+ }
459
+
460
+ module.exports = {
461
+ RedisRateLimiter
462
+ };