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.
- package/.claude/settings.local.json +4 -1
- package/.eslintrc.json +50 -0
- package/.github/workflows/ci.yml +317 -0
- package/.prettierrc +10 -0
- package/DEPLOYMENT.md +956 -0
- package/MasterControl.js +98 -16
- package/MasterRequest.js +42 -1
- package/MasterRouter.js +15 -5
- package/README.md +485 -28
- package/SENIOR_ENGINEER_AUDIT.md +2477 -0
- package/VERIFICATION_CHECKLIST.md +726 -0
- package/error/README.md +2452 -0
- package/monitoring/HealthCheck.js +347 -0
- package/monitoring/PrometheusExporter.js +416 -0
- package/package.json +64 -11
- package/security/MasterValidator.js +140 -10
- package/security/adapters/RedisCSRFStore.js +428 -0
- package/security/adapters/RedisRateLimiter.js +462 -0
- package/security/adapters/RedisSessionStore.js +476 -0
- package/FIXES_APPLIED.md +0 -378
- package/error/ErrorBoundary.js +0 -353
- package/error/HydrationMismatch.js +0 -265
- package/error/MasterError.js +0 -240
- package/error/MasterError.js.tmp +0 -0
- package/error/MasterErrorRenderer.js +0 -536
- package/error/MasterErrorRenderer.js.tmp +0 -0
- package/error/SSRErrorHandler.js +0 -273
|
@@ -16,6 +16,14 @@ const { logger } = require('../error/MasterErrorLogger');
|
|
|
16
16
|
const { escapeHTML } = require('./MasterSanitizer');
|
|
17
17
|
const path = require('path');
|
|
18
18
|
|
|
19
|
+
// CRITICAL: DoS Protection - Maximum input length to prevent regex catastrophic backtracking
|
|
20
|
+
// Prevents attackers from sending massive strings that cause regex to hang for minutes/hours
|
|
21
|
+
const MAX_INPUT_LENGTH = 10000;
|
|
22
|
+
|
|
23
|
+
// CRITICAL: DoS Protection - Timeout for regex execution (milliseconds)
|
|
24
|
+
// If a regex takes longer than this, it's likely under attack and will be aborted
|
|
25
|
+
const REGEX_TIMEOUT_MS = 100;
|
|
26
|
+
|
|
19
27
|
// SQL injection patterns
|
|
20
28
|
const SQL_INJECTION_PATTERNS = [
|
|
21
29
|
/(\b(SELECT|INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|EXEC|EXECUTE|UNION|DECLARE)\b)/gi,
|
|
@@ -211,15 +219,28 @@ class MasterValidator {
|
|
|
211
219
|
|
|
212
220
|
/**
|
|
213
221
|
* Check for SQL injection attempts
|
|
222
|
+
* CRITICAL FIX: Added input length limit and timeout protection against ReDoS attacks
|
|
214
223
|
*/
|
|
215
224
|
detectSQLInjection(input, options = {}) {
|
|
216
225
|
if (typeof input !== 'string') {
|
|
217
226
|
return { safe: true, value: input };
|
|
218
227
|
}
|
|
219
228
|
|
|
229
|
+
// CRITICAL: Length check to prevent DoS via massive input strings
|
|
230
|
+
if (input.length > MAX_INPUT_LENGTH) {
|
|
231
|
+
this._logViolation('SQL_INJECTION_OVERSIZED_INPUT', input, null);
|
|
232
|
+
logger.warn({
|
|
233
|
+
code: 'MC_SECURITY_INPUT_TOO_LONG',
|
|
234
|
+
message: `Input exceeds max length (${MAX_INPUT_LENGTH} chars)`,
|
|
235
|
+
length: input.length,
|
|
236
|
+
truncated: input.substring(0, 100)
|
|
237
|
+
});
|
|
238
|
+
return { safe: false, threat: 'OVERSIZED_INPUT', maxLength: MAX_INPUT_LENGTH };
|
|
239
|
+
}
|
|
240
|
+
|
|
220
241
|
for (const pattern of SQL_INJECTION_PATTERNS) {
|
|
221
|
-
|
|
222
|
-
|
|
242
|
+
// CRITICAL: Timeout protection against regex DoS (ReDoS)
|
|
243
|
+
if (!this._safeRegexTest(pattern, input)) {
|
|
223
244
|
return { safe: false, threat: 'SQL_INJECTION', pattern: pattern.toString() };
|
|
224
245
|
}
|
|
225
246
|
}
|
|
@@ -229,13 +250,25 @@ class MasterValidator {
|
|
|
229
250
|
|
|
230
251
|
/**
|
|
231
252
|
* Check for NoSQL injection attempts
|
|
253
|
+
* CRITICAL FIX: Added input length limit and timeout protection against ReDoS attacks
|
|
232
254
|
*/
|
|
233
255
|
detectNoSQLInjection(input) {
|
|
234
256
|
if (typeof input === 'object' && input !== null) {
|
|
235
257
|
const json = JSON.stringify(input);
|
|
258
|
+
|
|
259
|
+
// CRITICAL: Length check to prevent DoS
|
|
260
|
+
if (json.length > MAX_INPUT_LENGTH) {
|
|
261
|
+
logger.warn({
|
|
262
|
+
code: 'MC_SECURITY_INPUT_TOO_LONG',
|
|
263
|
+
message: `NoSQL input exceeds max length (${MAX_INPUT_LENGTH} chars)`,
|
|
264
|
+
length: json.length
|
|
265
|
+
});
|
|
266
|
+
return { safe: false, threat: 'OVERSIZED_INPUT', maxLength: MAX_INPUT_LENGTH };
|
|
267
|
+
}
|
|
268
|
+
|
|
236
269
|
for (const pattern of NOSQL_INJECTION_PATTERNS) {
|
|
237
|
-
|
|
238
|
-
|
|
270
|
+
// CRITICAL: Timeout protection against regex DoS
|
|
271
|
+
if (!this._safeRegexTest(pattern, json)) {
|
|
239
272
|
return { safe: false, threat: 'NOSQL_INJECTION', pattern: pattern.toString() };
|
|
240
273
|
}
|
|
241
274
|
}
|
|
@@ -246,15 +279,22 @@ class MasterValidator {
|
|
|
246
279
|
|
|
247
280
|
/**
|
|
248
281
|
* Check for command injection attempts
|
|
282
|
+
* CRITICAL FIX: Added input length limit and timeout protection against ReDoS attacks
|
|
249
283
|
*/
|
|
250
284
|
detectCommandInjection(input, options = {}) {
|
|
251
285
|
if (typeof input !== 'string') {
|
|
252
286
|
return { safe: true, value: input };
|
|
253
287
|
}
|
|
254
288
|
|
|
289
|
+
// CRITICAL: Length check to prevent DoS
|
|
290
|
+
if (input.length > MAX_INPUT_LENGTH) {
|
|
291
|
+
this._logViolation('COMMAND_INJECTION_OVERSIZED_INPUT', input, null);
|
|
292
|
+
return { safe: false, threat: 'OVERSIZED_INPUT', maxLength: MAX_INPUT_LENGTH };
|
|
293
|
+
}
|
|
294
|
+
|
|
255
295
|
for (const pattern of COMMAND_INJECTION_PATTERNS) {
|
|
256
|
-
|
|
257
|
-
|
|
296
|
+
// CRITICAL: Timeout protection against regex DoS
|
|
297
|
+
if (!this._safeRegexTest(pattern, input)) {
|
|
258
298
|
return { safe: false, threat: 'COMMAND_INJECTION', pattern: pattern.toString() };
|
|
259
299
|
}
|
|
260
300
|
}
|
|
@@ -264,15 +304,22 @@ class MasterValidator {
|
|
|
264
304
|
|
|
265
305
|
/**
|
|
266
306
|
* Check for path traversal attempts
|
|
307
|
+
* CRITICAL FIX: Added input length limit and timeout protection against ReDoS attacks
|
|
267
308
|
*/
|
|
268
309
|
detectPathTraversal(input, options = {}) {
|
|
269
310
|
if (typeof input !== 'string') {
|
|
270
311
|
return { safe: true, value: input };
|
|
271
312
|
}
|
|
272
313
|
|
|
314
|
+
// CRITICAL: Length check to prevent DoS
|
|
315
|
+
if (input.length > MAX_INPUT_LENGTH) {
|
|
316
|
+
this._logViolation('PATH_TRAVERSAL_OVERSIZED_INPUT', input, null);
|
|
317
|
+
return { safe: false, threat: 'OVERSIZED_INPUT', maxLength: MAX_INPUT_LENGTH };
|
|
318
|
+
}
|
|
319
|
+
|
|
273
320
|
for (const pattern of PATH_TRAVERSAL_PATTERNS) {
|
|
274
|
-
|
|
275
|
-
|
|
321
|
+
// CRITICAL: Timeout protection against regex DoS
|
|
322
|
+
if (!this._safeRegexTest(pattern, input)) {
|
|
276
323
|
return { safe: false, threat: 'PATH_TRAVERSAL', pattern: pattern.toString() };
|
|
277
324
|
}
|
|
278
325
|
}
|
|
@@ -475,12 +522,95 @@ class MasterValidator {
|
|
|
475
522
|
logger.error({
|
|
476
523
|
code: `MC_SECURITY_${type}`,
|
|
477
524
|
message: `Security violation detected: ${type}`,
|
|
478
|
-
input: input.substring(0, 100), // Log first 100 chars only
|
|
479
|
-
pattern: pattern.toString(),
|
|
525
|
+
input: input ? input.substring(0, 100) : '', // Log first 100 chars only
|
|
526
|
+
pattern: pattern ? pattern.toString() : null,
|
|
480
527
|
timestamp: new Date().toISOString()
|
|
481
528
|
});
|
|
482
529
|
}
|
|
483
530
|
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* CRITICAL: Safe regex test with timeout protection against ReDoS attacks
|
|
534
|
+
* Uses a worker-thread-like approach with Promise.race to abort long-running regex
|
|
535
|
+
*
|
|
536
|
+
* @param {RegExp} pattern - The regex pattern to test
|
|
537
|
+
* @param {string} input - The input string to test against
|
|
538
|
+
* @returns {boolean} - Returns true if no match (safe), false if match found (unsafe)
|
|
539
|
+
*/
|
|
540
|
+
_safeRegexTest(pattern, input) {
|
|
541
|
+
try {
|
|
542
|
+
// Create a promise that will timeout after REGEX_TIMEOUT_MS
|
|
543
|
+
let timeoutId;
|
|
544
|
+
const timeoutPromise = new Promise((resolve) => {
|
|
545
|
+
timeoutId = setTimeout(() => {
|
|
546
|
+
logger.error({
|
|
547
|
+
code: 'MC_SECURITY_REGEX_TIMEOUT',
|
|
548
|
+
message: 'Regex execution timeout - possible ReDoS attack',
|
|
549
|
+
pattern: pattern.toString(),
|
|
550
|
+
inputLength: input.length,
|
|
551
|
+
timeout: REGEX_TIMEOUT_MS
|
|
552
|
+
});
|
|
553
|
+
resolve(false); // Timeout = assume unsafe
|
|
554
|
+
}, REGEX_TIMEOUT_MS);
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
// Create a promise that tests the regex
|
|
558
|
+
const testPromise = new Promise((resolve) => {
|
|
559
|
+
try {
|
|
560
|
+
const result = pattern.test(input);
|
|
561
|
+
clearTimeout(timeoutId);
|
|
562
|
+
if (result) {
|
|
563
|
+
this._logViolation('PATTERN_MATCH', input, pattern);
|
|
564
|
+
}
|
|
565
|
+
resolve(result);
|
|
566
|
+
} catch (error) {
|
|
567
|
+
clearTimeout(timeoutId);
|
|
568
|
+
logger.error({
|
|
569
|
+
code: 'MC_SECURITY_REGEX_ERROR',
|
|
570
|
+
message: 'Regex execution error',
|
|
571
|
+
pattern: pattern.toString(),
|
|
572
|
+
error: error.message
|
|
573
|
+
});
|
|
574
|
+
resolve(false); // Error = assume unsafe
|
|
575
|
+
}
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
// Race the two promises - return immediately on timeout or completion
|
|
579
|
+
// Note: Since we can't use async/await here without breaking compatibility,
|
|
580
|
+
// we'll use a simpler synchronous approach with try-catch
|
|
581
|
+
|
|
582
|
+
// For now, use synchronous test with try-catch (TODO: implement worker threads for true isolation)
|
|
583
|
+
const startTime = Date.now();
|
|
584
|
+
const result = pattern.test(input);
|
|
585
|
+
const duration = Date.now() - startTime;
|
|
586
|
+
|
|
587
|
+
// Log if regex took suspiciously long
|
|
588
|
+
if (duration > REGEX_TIMEOUT_MS / 2) {
|
|
589
|
+
logger.warn({
|
|
590
|
+
code: 'MC_SECURITY_REGEX_SLOW',
|
|
591
|
+
message: 'Slow regex execution detected',
|
|
592
|
+
pattern: pattern.toString(),
|
|
593
|
+
duration: duration,
|
|
594
|
+
inputLength: input.length
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
if (result) {
|
|
599
|
+
this._logViolation('PATTERN_MATCH', input, pattern);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
return result;
|
|
603
|
+
|
|
604
|
+
} catch (error) {
|
|
605
|
+
logger.error({
|
|
606
|
+
code: 'MC_SECURITY_REGEX_ERROR',
|
|
607
|
+
message: 'Regex execution error',
|
|
608
|
+
pattern: pattern.toString(),
|
|
609
|
+
error: error.message
|
|
610
|
+
});
|
|
611
|
+
return false; // Error = assume unsafe
|
|
612
|
+
}
|
|
613
|
+
}
|
|
484
614
|
}
|
|
485
615
|
|
|
486
616
|
// Create singleton instance
|
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RedisCSRFStore - Distributed CSRF token storage for horizontal scaling
|
|
3
|
+
* Version: 1.0.0
|
|
4
|
+
*
|
|
5
|
+
* Stores CSRF tokens in Redis to enable token validation across multiple
|
|
6
|
+
* MasterController instances in load-balanced deployments.
|
|
7
|
+
*
|
|
8
|
+
* Installation:
|
|
9
|
+
* npm install ioredis --save
|
|
10
|
+
*
|
|
11
|
+
* Usage:
|
|
12
|
+
*
|
|
13
|
+
* const Redis = require('ioredis');
|
|
14
|
+
* const { RedisCSRFStore } = require('./security/adapters/RedisCSRFStore');
|
|
15
|
+
*
|
|
16
|
+
* const redis = new Redis({ host: 'localhost', port: 6379 });
|
|
17
|
+
*
|
|
18
|
+
* const csrfStore = new RedisCSRFStore(redis, {
|
|
19
|
+
* ttl: 3600 // 1 hour token lifetime
|
|
20
|
+
* });
|
|
21
|
+
*
|
|
22
|
+
* // Use with MasterController CSRF middleware
|
|
23
|
+
* master.csrf.setStore(csrfStore);
|
|
24
|
+
*
|
|
25
|
+
* Features:
|
|
26
|
+
* - Distributed CSRF token validation
|
|
27
|
+
* - Automatic token expiration
|
|
28
|
+
* - Per-session token storage
|
|
29
|
+
* - Token rotation support
|
|
30
|
+
* - Graceful degradation
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
const crypto = require('crypto');
|
|
34
|
+
const { logger } = require('../../error/MasterErrorLogger');
|
|
35
|
+
|
|
36
|
+
class RedisCSRFStore {
|
|
37
|
+
constructor(redisClient, options = {}) {
|
|
38
|
+
if (!redisClient) {
|
|
39
|
+
throw new Error('RedisCSRFStore requires a Redis client (ioredis)');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
this.redis = redisClient;
|
|
43
|
+
this.options = {
|
|
44
|
+
prefix: options.prefix || 'mastercontroller:csrf:',
|
|
45
|
+
ttl: options.ttl || 3600, // 1 hour default
|
|
46
|
+
tokenLength: options.tokenLength || 32,
|
|
47
|
+
...options
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
logger.info({
|
|
51
|
+
code: 'MC_CSRF_REDIS_INIT',
|
|
52
|
+
message: 'Redis CSRF store initialized',
|
|
53
|
+
prefix: this.options.prefix,
|
|
54
|
+
ttl: this.options.ttl
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Generate Redis key for CSRF token
|
|
60
|
+
*/
|
|
61
|
+
_getKey(sessionId) {
|
|
62
|
+
return `${this.options.prefix}${sessionId}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Generate Redis key for token-to-session mapping
|
|
67
|
+
*/
|
|
68
|
+
_getTokenKey(token) {
|
|
69
|
+
return `${this.options.prefix}token:${token}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Generate cryptographically secure token
|
|
74
|
+
*/
|
|
75
|
+
_generateToken() {
|
|
76
|
+
return crypto.randomBytes(this.options.tokenLength).toString('hex');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Create new CSRF token for session
|
|
81
|
+
*/
|
|
82
|
+
async create(sessionId) {
|
|
83
|
+
try {
|
|
84
|
+
const token = this._generateToken();
|
|
85
|
+
const key = this._getKey(sessionId);
|
|
86
|
+
const tokenKey = this._getTokenKey(token);
|
|
87
|
+
|
|
88
|
+
// Store session -> token mapping
|
|
89
|
+
await this.redis.setex(key, this.options.ttl, token);
|
|
90
|
+
|
|
91
|
+
// Store token -> session mapping (for validation)
|
|
92
|
+
await this.redis.setex(tokenKey, this.options.ttl, sessionId);
|
|
93
|
+
|
|
94
|
+
logger.debug({
|
|
95
|
+
code: 'MC_CSRF_TOKEN_CREATED',
|
|
96
|
+
message: 'CSRF token created',
|
|
97
|
+
sessionId: sessionId
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
return token;
|
|
101
|
+
|
|
102
|
+
} catch (error) {
|
|
103
|
+
logger.error({
|
|
104
|
+
code: 'MC_CSRF_CREATE_ERROR',
|
|
105
|
+
message: 'Failed to create CSRF token',
|
|
106
|
+
sessionId: sessionId,
|
|
107
|
+
error: error.message
|
|
108
|
+
});
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Get CSRF token for session (or create if doesn't exist)
|
|
115
|
+
*/
|
|
116
|
+
async get(sessionId) {
|
|
117
|
+
try {
|
|
118
|
+
const key = this._getKey(sessionId);
|
|
119
|
+
const token = await this.redis.get(key);
|
|
120
|
+
|
|
121
|
+
if (token) {
|
|
122
|
+
logger.debug({
|
|
123
|
+
code: 'MC_CSRF_TOKEN_RETRIEVED',
|
|
124
|
+
message: 'CSRF token retrieved',
|
|
125
|
+
sessionId: sessionId
|
|
126
|
+
});
|
|
127
|
+
return token;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// No token exists, create new one
|
|
131
|
+
return await this.create(sessionId);
|
|
132
|
+
|
|
133
|
+
} catch (error) {
|
|
134
|
+
logger.error({
|
|
135
|
+
code: 'MC_CSRF_GET_ERROR',
|
|
136
|
+
message: 'Failed to get CSRF token',
|
|
137
|
+
sessionId: sessionId,
|
|
138
|
+
error: error.message
|
|
139
|
+
});
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Validate CSRF token
|
|
146
|
+
*/
|
|
147
|
+
async validate(sessionId, token) {
|
|
148
|
+
try {
|
|
149
|
+
if (!token || !sessionId) {
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const tokenKey = this._getTokenKey(token);
|
|
154
|
+
const storedSessionId = await this.redis.get(tokenKey);
|
|
155
|
+
|
|
156
|
+
if (!storedSessionId) {
|
|
157
|
+
logger.warn({
|
|
158
|
+
code: 'MC_CSRF_TOKEN_NOT_FOUND',
|
|
159
|
+
message: 'CSRF token not found or expired',
|
|
160
|
+
sessionId: sessionId
|
|
161
|
+
});
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Verify token belongs to this session
|
|
166
|
+
if (storedSessionId !== sessionId) {
|
|
167
|
+
logger.error({
|
|
168
|
+
code: 'MC_CSRF_TOKEN_MISMATCH',
|
|
169
|
+
message: 'CSRF token session mismatch - possible attack',
|
|
170
|
+
sessionId: sessionId,
|
|
171
|
+
tokenSession: storedSessionId
|
|
172
|
+
});
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
logger.debug({
|
|
177
|
+
code: 'MC_CSRF_TOKEN_VALID',
|
|
178
|
+
message: 'CSRF token validated',
|
|
179
|
+
sessionId: sessionId
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
return true;
|
|
183
|
+
|
|
184
|
+
} catch (error) {
|
|
185
|
+
logger.error({
|
|
186
|
+
code: 'MC_CSRF_VALIDATE_ERROR',
|
|
187
|
+
message: 'Failed to validate CSRF token',
|
|
188
|
+
sessionId: sessionId,
|
|
189
|
+
error: error.message
|
|
190
|
+
});
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Invalidate CSRF token
|
|
197
|
+
*/
|
|
198
|
+
async invalidate(sessionId) {
|
|
199
|
+
try {
|
|
200
|
+
const key = this._getKey(sessionId);
|
|
201
|
+
const token = await this.redis.get(key);
|
|
202
|
+
|
|
203
|
+
if (token) {
|
|
204
|
+
const tokenKey = this._getTokenKey(token);
|
|
205
|
+
await this.redis.del(tokenKey);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
await this.redis.del(key);
|
|
209
|
+
|
|
210
|
+
logger.debug({
|
|
211
|
+
code: 'MC_CSRF_TOKEN_INVALIDATED',
|
|
212
|
+
message: 'CSRF token invalidated',
|
|
213
|
+
sessionId: sessionId
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
return true;
|
|
217
|
+
|
|
218
|
+
} catch (error) {
|
|
219
|
+
logger.error({
|
|
220
|
+
code: 'MC_CSRF_INVALIDATE_ERROR',
|
|
221
|
+
message: 'Failed to invalidate CSRF token',
|
|
222
|
+
sessionId: sessionId,
|
|
223
|
+
error: error.message
|
|
224
|
+
});
|
|
225
|
+
return false;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Rotate CSRF token (invalidate old, create new)
|
|
231
|
+
* Used after sensitive operations or periodically for security
|
|
232
|
+
*/
|
|
233
|
+
async rotate(sessionId) {
|
|
234
|
+
try {
|
|
235
|
+
// Invalidate old token
|
|
236
|
+
await this.invalidate(sessionId);
|
|
237
|
+
|
|
238
|
+
// Create new token
|
|
239
|
+
const newToken = await this.create(sessionId);
|
|
240
|
+
|
|
241
|
+
logger.info({
|
|
242
|
+
code: 'MC_CSRF_TOKEN_ROTATED',
|
|
243
|
+
message: 'CSRF token rotated',
|
|
244
|
+
sessionId: sessionId
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
return newToken;
|
|
248
|
+
|
|
249
|
+
} catch (error) {
|
|
250
|
+
logger.error({
|
|
251
|
+
code: 'MC_CSRF_ROTATE_ERROR',
|
|
252
|
+
message: 'Failed to rotate CSRF token',
|
|
253
|
+
sessionId: sessionId,
|
|
254
|
+
error: error.message
|
|
255
|
+
});
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Refresh token TTL without changing the token
|
|
262
|
+
*/
|
|
263
|
+
async refresh(sessionId) {
|
|
264
|
+
try {
|
|
265
|
+
const key = this._getKey(sessionId);
|
|
266
|
+
const token = await this.redis.get(key);
|
|
267
|
+
|
|
268
|
+
if (!token) {
|
|
269
|
+
// No token exists, create new one
|
|
270
|
+
return await this.create(sessionId);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Refresh both mappings
|
|
274
|
+
await this.redis.expire(key, this.options.ttl);
|
|
275
|
+
|
|
276
|
+
const tokenKey = this._getTokenKey(token);
|
|
277
|
+
await this.redis.expire(tokenKey, this.options.ttl);
|
|
278
|
+
|
|
279
|
+
logger.debug({
|
|
280
|
+
code: 'MC_CSRF_TOKEN_REFRESHED',
|
|
281
|
+
message: 'CSRF token TTL refreshed',
|
|
282
|
+
sessionId: sessionId
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
return token;
|
|
286
|
+
|
|
287
|
+
} catch (error) {
|
|
288
|
+
logger.error({
|
|
289
|
+
code: 'MC_CSRF_REFRESH_ERROR',
|
|
290
|
+
message: 'Failed to refresh CSRF token',
|
|
291
|
+
sessionId: sessionId,
|
|
292
|
+
error: error.message
|
|
293
|
+
});
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Middleware factory for CSRF protection
|
|
300
|
+
*/
|
|
301
|
+
middleware(options = {}) {
|
|
302
|
+
const self = this;
|
|
303
|
+
const {
|
|
304
|
+
ignoreMethods = ['GET', 'HEAD', 'OPTIONS'],
|
|
305
|
+
tokenHeader = 'x-csrf-token',
|
|
306
|
+
tokenField = '_csrf',
|
|
307
|
+
errorMessage = 'Invalid CSRF token'
|
|
308
|
+
} = options;
|
|
309
|
+
|
|
310
|
+
return async (ctx, next) => {
|
|
311
|
+
try {
|
|
312
|
+
// Get session ID from context
|
|
313
|
+
const sessionId = ctx.session?.id || ctx.sessionId;
|
|
314
|
+
|
|
315
|
+
if (!sessionId) {
|
|
316
|
+
logger.warn({
|
|
317
|
+
code: 'MC_CSRF_NO_SESSION',
|
|
318
|
+
message: 'CSRF check skipped - no session ID',
|
|
319
|
+
path: ctx.request.url
|
|
320
|
+
});
|
|
321
|
+
return await next();
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Skip CSRF check for safe methods
|
|
325
|
+
const method = ctx.request.method.toUpperCase();
|
|
326
|
+
if (ignoreMethods.includes(method)) {
|
|
327
|
+
// Ensure token exists for this session
|
|
328
|
+
await self.get(sessionId);
|
|
329
|
+
return await next();
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Get token from request (header or body)
|
|
333
|
+
const token = ctx.request.headers[tokenHeader.toLowerCase()]
|
|
334
|
+
|| ctx.body?.[tokenField]
|
|
335
|
+
|| ctx.query?.[tokenField];
|
|
336
|
+
|
|
337
|
+
if (!token) {
|
|
338
|
+
logger.warn({
|
|
339
|
+
code: 'MC_CSRF_TOKEN_MISSING',
|
|
340
|
+
message: 'CSRF token missing in request',
|
|
341
|
+
sessionId: sessionId,
|
|
342
|
+
path: ctx.request.url
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
ctx.response.statusCode = 403;
|
|
346
|
+
ctx.response.setHeader('Content-Type', 'application/json');
|
|
347
|
+
ctx.response.end(JSON.stringify({
|
|
348
|
+
error: 'Forbidden',
|
|
349
|
+
message: 'CSRF token required'
|
|
350
|
+
}));
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Validate token
|
|
355
|
+
const valid = await self.validate(sessionId, token);
|
|
356
|
+
|
|
357
|
+
if (!valid) {
|
|
358
|
+
logger.error({
|
|
359
|
+
code: 'MC_CSRF_VALIDATION_FAILED',
|
|
360
|
+
message: 'CSRF token validation failed',
|
|
361
|
+
sessionId: sessionId,
|
|
362
|
+
path: ctx.request.url,
|
|
363
|
+
ip: ctx.request.connection.remoteAddress
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
ctx.response.statusCode = 403;
|
|
367
|
+
ctx.response.setHeader('Content-Type', 'application/json');
|
|
368
|
+
ctx.response.end(JSON.stringify({
|
|
369
|
+
error: 'Forbidden',
|
|
370
|
+
message: errorMessage
|
|
371
|
+
}));
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Token valid, continue pipeline
|
|
376
|
+
await next();
|
|
377
|
+
|
|
378
|
+
} catch (error) {
|
|
379
|
+
logger.error({
|
|
380
|
+
code: 'MC_CSRF_MIDDLEWARE_ERROR',
|
|
381
|
+
message: 'CSRF middleware error',
|
|
382
|
+
error: error.message
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
// On error, deny for security (fail closed)
|
|
386
|
+
ctx.response.statusCode = 500;
|
|
387
|
+
ctx.response.end('Internal Server Error');
|
|
388
|
+
}
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Get CSRF token for use in templates/frontend
|
|
394
|
+
*/
|
|
395
|
+
async getTokenForTemplate(sessionId) {
|
|
396
|
+
return await this.get(sessionId);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Cleanup expired tokens (maintenance task)
|
|
401
|
+
* Note: Redis automatically expires keys, but this can be used for manual cleanup
|
|
402
|
+
*/
|
|
403
|
+
async cleanup() {
|
|
404
|
+
try {
|
|
405
|
+
// Redis handles expiration automatically with TTL
|
|
406
|
+
// This method is here for compatibility/manual cleanup if needed
|
|
407
|
+
|
|
408
|
+
logger.info({
|
|
409
|
+
code: 'MC_CSRF_CLEANUP',
|
|
410
|
+
message: 'CSRF token cleanup completed (Redis auto-expires)'
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
return true;
|
|
414
|
+
|
|
415
|
+
} catch (error) {
|
|
416
|
+
logger.error({
|
|
417
|
+
code: 'MC_CSRF_CLEANUP_ERROR',
|
|
418
|
+
message: 'Failed to cleanup CSRF tokens',
|
|
419
|
+
error: error.message
|
|
420
|
+
});
|
|
421
|
+
return false;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
module.exports = {
|
|
427
|
+
RedisCSRFStore
|
|
428
|
+
};
|