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
package/MasterControl.js
CHANGED
|
@@ -10,6 +10,7 @@ var fs = require('fs');
|
|
|
10
10
|
var url = require('url');
|
|
11
11
|
var path = require('path');
|
|
12
12
|
var globSearch = require("glob");
|
|
13
|
+
var crypto = require('crypto'); // CRITICAL FIX: For ETag generation
|
|
13
14
|
|
|
14
15
|
// Enhanced error handling - setup global handlers
|
|
15
16
|
const { setupGlobalErrorHandlers } = require('./error/MasterErrorMiddleware');
|
|
@@ -779,26 +780,107 @@ class MasterControl {
|
|
|
779
780
|
}
|
|
780
781
|
}
|
|
781
782
|
|
|
782
|
-
//
|
|
783
|
-
|
|
784
|
-
|
|
783
|
+
// CRITICAL FIX: Stream large files instead of reading into memory
|
|
784
|
+
// Files >1MB are streamed to prevent memory exhaustion and improve performance
|
|
785
|
+
const STREAM_THRESHOLD = 1 * 1024 * 1024; // 1MB
|
|
786
|
+
const fileSize = stats.isDirectory() ? fs.statSync(finalPath).size : stats.size;
|
|
787
|
+
const ext = path.extname(finalPath);
|
|
788
|
+
const mimeType = $that.router.findMimeType(ext);
|
|
789
|
+
|
|
790
|
+
// CRITICAL FIX: Generate ETag for caching (based on file stats)
|
|
791
|
+
// ETag format: "size-mtime" (weak ETag for better performance)
|
|
792
|
+
const fileStats = stats.isDirectory() ? fs.statSync(finalPath) : stats;
|
|
793
|
+
const etag = `W/"${fileStats.size}-${fileStats.mtime.getTime()}"`;
|
|
794
|
+
|
|
795
|
+
// CRITICAL FIX: Check If-None-Match header for 304 Not Modified
|
|
796
|
+
const clientETag = ctx.request.headers['if-none-match'];
|
|
797
|
+
if (clientETag === etag) {
|
|
798
|
+
// File hasn't changed, return 304 Not Modified
|
|
799
|
+
logger.debug({
|
|
800
|
+
code: 'MC_STATIC_304',
|
|
801
|
+
message: 'Returning 304 Not Modified',
|
|
802
|
+
path: finalPath,
|
|
803
|
+
etag: etag
|
|
804
|
+
});
|
|
805
|
+
ctx.response.statusCode = 304;
|
|
806
|
+
ctx.response.setHeader('ETag', etag);
|
|
807
|
+
ctx.response.end();
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
// Set common headers for both streaming and buffered responses
|
|
812
|
+
ctx.response.setHeader('Content-Type', mimeType || 'application/octet-stream');
|
|
813
|
+
ctx.response.setHeader('X-Content-Type-Options', 'nosniff');
|
|
814
|
+
ctx.response.setHeader('Content-Length', fileSize);
|
|
815
|
+
|
|
816
|
+
// CRITICAL FIX: Add caching headers
|
|
817
|
+
ctx.response.setHeader('ETag', etag);
|
|
818
|
+
ctx.response.setHeader('Last-Modified', fileStats.mtime.toUTCString());
|
|
819
|
+
|
|
820
|
+
// Cache-Control based on file type
|
|
821
|
+
const cacheableExtensions = ['.js', '.css', '.jpg', '.jpeg', '.png', '.gif', '.svg', '.woff', '.woff2', '.ttf', '.eot', '.ico'];
|
|
822
|
+
const isCacheable = cacheableExtensions.includes(ext.toLowerCase());
|
|
823
|
+
|
|
824
|
+
if (isCacheable) {
|
|
825
|
+
// PERFORMANCE: Cache static assets for 1 year (immutable pattern)
|
|
826
|
+
// Use versioned URLs (e.g., app.v123.js) for cache-busting
|
|
827
|
+
ctx.response.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
|
828
|
+
} else {
|
|
829
|
+
// SECURITY: Dynamic content should revalidate
|
|
830
|
+
ctx.response.setHeader('Cache-Control', 'public, max-age=0, must-revalidate');
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
if (fileSize > STREAM_THRESHOLD) {
|
|
834
|
+
// PERFORMANCE: Stream large files (>1MB) to avoid memory issues
|
|
835
|
+
logger.debug({
|
|
836
|
+
code: 'MC_STATIC_STREAMING',
|
|
837
|
+
message: 'Streaming large static file',
|
|
838
|
+
path: finalPath,
|
|
839
|
+
size: fileSize
|
|
840
|
+
});
|
|
841
|
+
|
|
842
|
+
const readStream = fs.createReadStream(finalPath);
|
|
843
|
+
|
|
844
|
+
readStream.on('error', (err) => {
|
|
785
845
|
logger.error({
|
|
786
|
-
code: '
|
|
787
|
-
message: 'Error
|
|
846
|
+
code: 'MC_ERR_STREAM_READ',
|
|
847
|
+
message: 'Error streaming static file',
|
|
788
848
|
path: finalPath,
|
|
789
849
|
error: err.message
|
|
790
850
|
});
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
ctx.response.
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
851
|
+
|
|
852
|
+
// Only send error if headers not sent
|
|
853
|
+
if (!ctx.response.headersSent) {
|
|
854
|
+
ctx.response.statusCode = 500;
|
|
855
|
+
ctx.response.setHeader('Content-Type', 'text/plain');
|
|
856
|
+
ctx.response.end('Internal Server Error');
|
|
857
|
+
} else {
|
|
858
|
+
// Connection already started, just close it
|
|
859
|
+
ctx.response.end();
|
|
860
|
+
}
|
|
861
|
+
});
|
|
862
|
+
|
|
863
|
+
// Pipe the file stream to the response
|
|
864
|
+
readStream.pipe(ctx.response);
|
|
865
|
+
|
|
866
|
+
} else {
|
|
867
|
+
// PERFORMANCE: Small files (<1MB) can be buffered for better caching
|
|
868
|
+
fs.readFile(finalPath, function(err, data) {
|
|
869
|
+
if (err) {
|
|
870
|
+
logger.error({
|
|
871
|
+
code: 'MC_ERR_FILE_READ',
|
|
872
|
+
message: 'Error reading static file',
|
|
873
|
+
path: finalPath,
|
|
874
|
+
error: err.message
|
|
875
|
+
});
|
|
876
|
+
ctx.response.statusCode = 500;
|
|
877
|
+
ctx.response.setHeader('Content-Type', 'text/plain');
|
|
878
|
+
ctx.response.end('Internal Server Error');
|
|
879
|
+
} else {
|
|
880
|
+
ctx.response.end(data);
|
|
881
|
+
}
|
|
882
|
+
});
|
|
883
|
+
}
|
|
802
884
|
});
|
|
803
885
|
|
|
804
886
|
return; // Terminal - don't call next()
|
package/MasterRequest.js
CHANGED
|
@@ -26,7 +26,20 @@ class MasterRequest{
|
|
|
26
26
|
if(options){
|
|
27
27
|
this.options = {};
|
|
28
28
|
this.options.disableFormidableMultipartFormData = options.disableFormidableMultipartFormData === null? false : options.disableFormidableMultipartFormData;
|
|
29
|
-
|
|
29
|
+
|
|
30
|
+
// CRITICAL FIX: Add file upload limits to prevent DoS attacks
|
|
31
|
+
// Default formidable configuration with security limits
|
|
32
|
+
this.options.formidable = {
|
|
33
|
+
maxFiles: 10, // CRITICAL: Limit number of files per request
|
|
34
|
+
maxFileSize: 50 * 1024 * 1024, // CRITICAL: 50MB per file (was unlimited)
|
|
35
|
+
maxTotalFileSize: 100 * 1024 * 1024, // CRITICAL: 100MB total for all files
|
|
36
|
+
maxFields: 1000, // Limit number of fields
|
|
37
|
+
maxFieldsSize: 20 * 1024 * 1024, // 20MB for all fields combined
|
|
38
|
+
allowEmptyFiles: false, // Reject empty file uploads
|
|
39
|
+
minFileSize: 1, // Minimum 1 byte
|
|
40
|
+
...(options.formidable || {}) // Allow user overrides
|
|
41
|
+
};
|
|
42
|
+
|
|
30
43
|
// Body size limits (DoS protection)
|
|
31
44
|
this.options.maxBodySize = options.maxBodySize || 10 * 1024 * 1024; // 10MB default
|
|
32
45
|
this.options.maxJsonSize = options.maxJsonSize || 1 * 1024 * 1024; // 1MB default for JSON
|
|
@@ -74,6 +87,7 @@ class MasterRequest{
|
|
|
74
87
|
// Track uploaded files for cleanup on error
|
|
75
88
|
const uploadedFiles = [];
|
|
76
89
|
let uploadAborted = false;
|
|
90
|
+
let totalUploadedSize = 0; // CRITICAL: Track total upload size
|
|
77
91
|
|
|
78
92
|
$that.form.on('field', function(field, value) {
|
|
79
93
|
$that.parsedURL.formData.fields[field] = value;
|
|
@@ -87,6 +101,30 @@ class MasterRequest{
|
|
|
87
101
|
$that.form.on('file', function(field, file) {
|
|
88
102
|
file.extension = file.name === undefined ? path.extname(file.originalFilename) : path.extname(file.name);
|
|
89
103
|
|
|
104
|
+
// CRITICAL: Track total uploaded size across all files
|
|
105
|
+
totalUploadedSize += file.size || 0;
|
|
106
|
+
|
|
107
|
+
// CRITICAL: Enforce maxTotalFileSize limit
|
|
108
|
+
const maxTotalSize = $that.options.formidable.maxTotalFileSize || 100 * 1024 * 1024;
|
|
109
|
+
if (totalUploadedSize > maxTotalSize) {
|
|
110
|
+
uploadAborted = true;
|
|
111
|
+
console.error(`[MasterRequest] Total upload size (${totalUploadedSize} bytes) exceeds limit (${maxTotalSize} bytes)`);
|
|
112
|
+
|
|
113
|
+
// Cleanup all uploaded files
|
|
114
|
+
uploadedFiles.forEach(f => {
|
|
115
|
+
if (f.filepath) {
|
|
116
|
+
try {
|
|
117
|
+
$that.deleteFileBuffer(f.filepath);
|
|
118
|
+
} catch (err) {
|
|
119
|
+
console.error('[MasterRequest] Cleanup failed:', err.message);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
reject(new Error(`Total upload size exceeds limit (${maxTotalSize} bytes)`));
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
90
128
|
if(Array.isArray($that.parsedURL.formData.files[field])){
|
|
91
129
|
$that.parsedURL.formData.files[field].push(file);
|
|
92
130
|
}
|
|
@@ -94,6 +132,9 @@ class MasterRequest{
|
|
|
94
132
|
$that.parsedURL.formData.files[field] = [];
|
|
95
133
|
$that.parsedURL.formData.files[field].push(file);
|
|
96
134
|
}
|
|
135
|
+
|
|
136
|
+
// CRITICAL: Log file upload for security audit trail
|
|
137
|
+
console.log(`[MasterRequest] File uploaded: ${file.originalFilename || file.name} (${file.size} bytes)`);
|
|
97
138
|
});
|
|
98
139
|
|
|
99
140
|
$that.form.on('error', function(err) {
|
package/MasterRouter.js
CHANGED
|
@@ -238,10 +238,14 @@ const isDevelopment = process.env.NODE_ENV !== 'production' && process.env.maste
|
|
|
238
238
|
}
|
|
239
239
|
};
|
|
240
240
|
|
|
241
|
-
|
|
241
|
+
// CRITICAL FIX: Race condition - Store scoped services in context instead of shared requestList
|
|
242
|
+
// Previously, concurrent requests would overwrite each other's services in the shared requestList object
|
|
243
|
+
// This caused unpredictable behavior and data corruption in production environments
|
|
244
|
+
var loadScopedListClasses = function(context){
|
|
242
245
|
// FIXED: Use Object.entries() for safe iteration (prevents prototype pollution)
|
|
243
246
|
for (const [key, className] of Object.entries(this._master._scopedList)) {
|
|
244
|
-
|
|
247
|
+
// Store scoped services in the context object (request-specific) instead of shared requestList
|
|
248
|
+
context[key] = new className();
|
|
245
249
|
}
|
|
246
250
|
};
|
|
247
251
|
|
|
@@ -419,8 +423,11 @@ class MasterRouter {
|
|
|
419
423
|
const requestId = `${Date.now()}-${Math.random()}`;
|
|
420
424
|
performanceTracker.start(requestId, requestObject);
|
|
421
425
|
|
|
422
|
-
|
|
423
|
-
|
|
426
|
+
// CRITICAL FIX: Create a request-specific context instead of using shared requestList
|
|
427
|
+
// This prevents race conditions where concurrent requests overwrite each other's services
|
|
428
|
+
const requestContext = Object.create(this._master.requestList);
|
|
429
|
+
tools.combineObjects(requestContext, requestObject);
|
|
430
|
+
requestObject = requestContext;
|
|
424
431
|
var Control = null;
|
|
425
432
|
|
|
426
433
|
try{
|
|
@@ -528,9 +535,12 @@ class MasterRouter {
|
|
|
528
535
|
|
|
529
536
|
load(rr){ // load the the router
|
|
530
537
|
|
|
531
|
-
loadScopedListClasses.call(this);
|
|
532
538
|
var $that = this;
|
|
533
539
|
var requestObject = Object.create(rr);
|
|
540
|
+
|
|
541
|
+
// CRITICAL FIX: Load scoped services into request-specific context
|
|
542
|
+
// Pass requestObject so scoped services are stored per-request, not globally
|
|
543
|
+
loadScopedListClasses.call(this, requestObject);
|
|
534
544
|
requestObject.pathName = requestObject.pathName.replace(/^\/|\/$/g, '').toLowerCase();
|
|
535
545
|
|
|
536
546
|
var _loadEmit = new EventEmitter();
|