mastercontroller 1.3.13 → 1.3.15
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/MasterAction.js +302 -62
- package/MasterActionFilters.js +556 -82
- package/MasterControl.js +77 -44
- package/MasterCors.js +61 -19
- package/MasterPipeline.js +29 -6
- package/MasterRequest.js +579 -102
- package/MasterRouter.js +446 -75
- package/MasterSocket.js +380 -15
- package/MasterTemp.js +292 -10
- package/MasterTimeout.js +420 -64
- package/MasterTools.js +478 -77
- package/README.md +505 -0
- package/package.json +1 -1
- package/.claude/settings.local.json +0 -29
- package/.github/workflows/ci.yml +0 -317
- package/PERFORMANCE_SECURITY_AUDIT.md +0 -677
- package/SENIOR_ENGINEER_AUDIT.md +0 -2477
- package/VERIFICATION_CHECKLIST.md +0 -726
- package/log/mastercontroller.log +0 -2
- package/test-json-empty-body.js +0 -76
- package/test-raw-body-preservation.js +0 -128
- package/test-v1.3.4-fixes.js +0 -129
package/MasterRequest.js
CHANGED
|
@@ -1,18 +1,56 @@
|
|
|
1
1
|
|
|
2
2
|
// version 0.0.2
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
const url = require('url');
|
|
5
5
|
const StringDecoder = require('string_decoder').StringDecoder;
|
|
6
|
-
|
|
6
|
+
const qs = require('qs');
|
|
7
7
|
const formidable = require('formidable');
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
const contentTypeManager = require("content-type");
|
|
9
|
+
const path = require('path');
|
|
10
10
|
const fs = require('fs');
|
|
11
|
-
|
|
11
|
+
const fsPromises = require('fs').promises;
|
|
12
|
+
const { logger } = require('./error/MasterErrorLogger');
|
|
13
|
+
|
|
14
|
+
// Content Type Constants
|
|
15
|
+
const CONTENT_TYPES = {
|
|
16
|
+
FORM_URLENCODED: 'application/x-www-form-urlencoded',
|
|
17
|
+
MULTIPART_FORM: 'multipart/form-data',
|
|
18
|
+
JSON: 'application/json',
|
|
19
|
+
HTML: 'text/html',
|
|
20
|
+
PLAIN: 'text/plain'
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
// Size Limit Constants (DoS Protection)
|
|
24
|
+
const SIZE_LIMITS = {
|
|
25
|
+
MAX_FILES: 10,
|
|
26
|
+
MAX_FILE_SIZE: 50 * 1024 * 1024, // 50MB per file
|
|
27
|
+
MAX_TOTAL_FILE_SIZE: 100 * 1024 * 1024, // 100MB total
|
|
28
|
+
MAX_FIELDS: 1000,
|
|
29
|
+
MAX_FIELDS_SIZE: 20 * 1024 * 1024, // 20MB for all fields
|
|
30
|
+
MAX_BODY_SIZE: 10 * 1024 * 1024, // 10MB default
|
|
31
|
+
MAX_JSON_SIZE: 1 * 1024 * 1024, // 1MB for JSON
|
|
32
|
+
MAX_TEXT_SIZE: 1 * 1024 * 1024 // 1MB for text
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* MasterRequest - Request parsing and stream handling
|
|
37
|
+
*
|
|
38
|
+
* Handles all incoming request parsing including:
|
|
39
|
+
* - URL-encoded form data
|
|
40
|
+
* - Multipart form data (file uploads)
|
|
41
|
+
* - JSON payloads
|
|
42
|
+
* - Plain text/HTML
|
|
43
|
+
* - Raw body preservation for webhook signature verification
|
|
44
|
+
*
|
|
45
|
+
* Includes DoS protection via configurable size limits
|
|
46
|
+
*
|
|
47
|
+
* @class MasterRequest
|
|
48
|
+
*/
|
|
12
49
|
class MasterRequest{
|
|
13
50
|
parsedURL = {};
|
|
14
51
|
request = {};
|
|
15
52
|
response = {};
|
|
53
|
+
__requestId = null;
|
|
16
54
|
|
|
17
55
|
// Lazy-load master to avoid circular dependency (Google-style lazy initialization)
|
|
18
56
|
get _master() {
|
|
@@ -22,7 +60,33 @@ class MasterRequest{
|
|
|
22
60
|
return this.__masterCache;
|
|
23
61
|
}
|
|
24
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Initialize request handler with configuration options
|
|
65
|
+
*
|
|
66
|
+
* @param {Object} options - Configuration options
|
|
67
|
+
* @param {boolean} [options.disableFormidableMultipartFormData=false] - Disable multipart parsing
|
|
68
|
+
* @param {Object} [options.formidable] - Formidable configuration
|
|
69
|
+
* @param {number} [options.formidable.maxFiles=10] - Maximum number of files
|
|
70
|
+
* @param {number} [options.formidable.maxFileSize=50MB] - Maximum file size in bytes
|
|
71
|
+
* @param {number} [options.formidable.maxTotalFileSize=100MB] - Maximum total upload size
|
|
72
|
+
* @param {number} [options.formidable.maxFields=1000] - Maximum number of form fields
|
|
73
|
+
* @param {number} [options.formidable.maxFieldsSize=20MB] - Maximum size for all fields
|
|
74
|
+
* @param {number} [options.maxBodySize=10MB] - Maximum URL-encoded body size
|
|
75
|
+
* @param {number} [options.maxJsonSize=1MB] - Maximum JSON payload size
|
|
76
|
+
* @param {number} [options.maxTextSize=1MB] - Maximum text payload size
|
|
77
|
+
* @returns {void}
|
|
78
|
+
* @example
|
|
79
|
+
* masterRequest.init({
|
|
80
|
+
* formidable: { maxFileSize: 10 * 1024 * 1024 },
|
|
81
|
+
* maxJsonSize: 2 * 1024 * 1024
|
|
82
|
+
* });
|
|
83
|
+
*/
|
|
25
84
|
init(options){
|
|
85
|
+
// Input validation
|
|
86
|
+
if (options !== undefined && (typeof options !== 'object' || options === null || Array.isArray(options))) {
|
|
87
|
+
throw new TypeError('init() options must be an object');
|
|
88
|
+
}
|
|
89
|
+
|
|
26
90
|
if(options){
|
|
27
91
|
this.options = {};
|
|
28
92
|
this.options.disableFormidableMultipartFormData = options.disableFormidableMultipartFormData === null? false : options.disableFormidableMultipartFormData;
|
|
@@ -30,32 +94,88 @@ class MasterRequest{
|
|
|
30
94
|
// CRITICAL FIX: Add file upload limits to prevent DoS attacks
|
|
31
95
|
// Default formidable configuration with security limits
|
|
32
96
|
this.options.formidable = {
|
|
33
|
-
maxFiles:
|
|
34
|
-
maxFileSize:
|
|
35
|
-
maxTotalFileSize:
|
|
36
|
-
maxFields:
|
|
37
|
-
maxFieldsSize:
|
|
38
|
-
allowEmptyFiles: false,
|
|
39
|
-
minFileSize: 1,
|
|
40
|
-
...(options.formidable || {})
|
|
97
|
+
maxFiles: SIZE_LIMITS.MAX_FILES,
|
|
98
|
+
maxFileSize: SIZE_LIMITS.MAX_FILE_SIZE,
|
|
99
|
+
maxTotalFileSize: SIZE_LIMITS.MAX_TOTAL_FILE_SIZE,
|
|
100
|
+
maxFields: SIZE_LIMITS.MAX_FIELDS,
|
|
101
|
+
maxFieldsSize: SIZE_LIMITS.MAX_FIELDS_SIZE,
|
|
102
|
+
allowEmptyFiles: false,
|
|
103
|
+
minFileSize: 1,
|
|
104
|
+
...(options.formidable || {})
|
|
41
105
|
};
|
|
42
106
|
|
|
43
107
|
// Body size limits (DoS protection)
|
|
44
|
-
this.options.maxBodySize = options.maxBodySize ||
|
|
45
|
-
this.options.maxJsonSize = options.maxJsonSize ||
|
|
46
|
-
this.options.maxTextSize = options.maxTextSize ||
|
|
108
|
+
this.options.maxBodySize = options.maxBodySize || SIZE_LIMITS.MAX_BODY_SIZE;
|
|
109
|
+
this.options.maxJsonSize = options.maxJsonSize || SIZE_LIMITS.MAX_JSON_SIZE;
|
|
110
|
+
this.options.maxTextSize = options.maxTextSize || SIZE_LIMITS.MAX_TEXT_SIZE;
|
|
47
111
|
}
|
|
48
112
|
}
|
|
49
113
|
|
|
114
|
+
/**
|
|
115
|
+
* Parse incoming request and extract parameters
|
|
116
|
+
*
|
|
117
|
+
* Supports both old pattern (request object) and new pattern (context object)
|
|
118
|
+
* for backward compatibility. Automatically detects content-type and routes to
|
|
119
|
+
* appropriate parser.
|
|
120
|
+
*
|
|
121
|
+
* Supported content types:
|
|
122
|
+
* - application/x-www-form-urlencoded
|
|
123
|
+
* - multipart/form-data (file uploads)
|
|
124
|
+
* - application/json
|
|
125
|
+
* - text/html
|
|
126
|
+
* - text/plain
|
|
127
|
+
*
|
|
128
|
+
* @param {Object|Request} requestOrContext - Request or context object
|
|
129
|
+
* @param {Object} requestOrContext.request - HTTP request (new pattern)
|
|
130
|
+
* @param {string} requestOrContext.requrl - Pre-parsed URL (optional)
|
|
131
|
+
* @param {Response} res - HTTP response object
|
|
132
|
+
* @returns {Promise<Object>} Parsed request data
|
|
133
|
+
* @returns {Object} parsedURL.query - Query string parameters
|
|
134
|
+
* @returns {Object} parsedURL.formData - Parsed body data
|
|
135
|
+
* @returns {Object} parsedURL.formData.fields - Form fields (multipart)
|
|
136
|
+
* @returns {Object} parsedURL.formData.files - Uploaded files (multipart)
|
|
137
|
+
* @returns {string} parsedURL.formData._rawBody - Raw body string (for webhook verification)
|
|
138
|
+
* @throws {Error} If upload size exceeds limits
|
|
139
|
+
* @throws {Error} If file upload fails
|
|
140
|
+
* @throws {Error} If content type parsing fails
|
|
141
|
+
* @example
|
|
142
|
+
* const parsedData = await masterRequest.getRequestParam(req, res);
|
|
143
|
+
* console.log(parsedData.query.id);
|
|
144
|
+
* console.log(parsedData.formData.fields.username);
|
|
145
|
+
*/
|
|
50
146
|
getRequestParam(requestOrContext, res){
|
|
51
|
-
|
|
147
|
+
// Input validation
|
|
148
|
+
if (!requestOrContext || typeof requestOrContext !== 'object') {
|
|
149
|
+
return Promise.reject(new TypeError('getRequestParam() requires a request or context object'));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (!res || typeof res !== 'object' || typeof res.writeHead !== 'function') {
|
|
153
|
+
return Promise.reject(new TypeError('getRequestParam() requires a valid response object'));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const $that = this;
|
|
52
157
|
$that.response = res;
|
|
53
|
-
|
|
54
|
-
|
|
158
|
+
|
|
159
|
+
// Return Promise with proper error handling
|
|
160
|
+
return new Promise(function (resolve, reject) {
|
|
161
|
+
try {
|
|
55
162
|
// BACKWARD COMPATIBILITY: Support both old and new patterns
|
|
56
163
|
// New pattern (v1.3.x pipeline): Pass context with requrl property
|
|
57
164
|
// Old pattern (pre-v1.3.x): Pass request with requrl property
|
|
58
165
|
const request = requestOrContext.request || requestOrContext;
|
|
166
|
+
|
|
167
|
+
// SECURITY: Validate headers before processing
|
|
168
|
+
const securityCheck = $that._validateSecurityHeaders(request);
|
|
169
|
+
if (!securityCheck.valid) {
|
|
170
|
+
logger.warn({
|
|
171
|
+
code: 'MC_REQ_SECURITY_VALIDATION_FAILED',
|
|
172
|
+
message: 'Request failed security validation',
|
|
173
|
+
requestId: $that.getRequestId(),
|
|
174
|
+
reason: securityCheck.reason
|
|
175
|
+
});
|
|
176
|
+
reject(new Error(securityCheck.reason));
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
59
179
|
let requrl = requestOrContext.requrl || request.requrl;
|
|
60
180
|
|
|
61
181
|
// Fallback: If requrl not set, parse from request.url
|
|
@@ -63,19 +183,19 @@ class MasterRequest{
|
|
|
63
183
|
requrl = url.parse(request.url, true);
|
|
64
184
|
}
|
|
65
185
|
|
|
66
|
-
|
|
186
|
+
const querydata = url.parse(requrl, true);
|
|
67
187
|
$that.parsedURL.query = querydata.query;
|
|
68
188
|
$that.form = new formidable.IncomingForm($that.options.formidable);
|
|
69
189
|
if(request.headers['content-type'] || request.headers['transfer-encoding'] ){
|
|
70
190
|
var contentType = contentTypeManager.parse(request);
|
|
71
191
|
switch(contentType.type){
|
|
72
|
-
case
|
|
192
|
+
case CONTENT_TYPES.FORM_URLENCODED:
|
|
73
193
|
$that.urlEncodeStream(request, function(data){
|
|
74
194
|
$that.parsedURL.formData = data;
|
|
75
195
|
resolve($that.parsedURL);
|
|
76
196
|
});
|
|
77
197
|
break
|
|
78
|
-
case
|
|
198
|
+
case CONTENT_TYPES.MULTIPART_FORM:
|
|
79
199
|
// Offer operturnity to add options. find a way to add dependecy injection. to request
|
|
80
200
|
if(!$that.options.disableFormidableMultipartFormData){
|
|
81
201
|
|
|
@@ -108,17 +228,21 @@ class MasterRequest{
|
|
|
108
228
|
const maxTotalSize = $that.options.formidable.maxTotalFileSize || 100 * 1024 * 1024;
|
|
109
229
|
if (totalUploadedSize > maxTotalSize) {
|
|
110
230
|
uploadAborted = true;
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
231
|
+
logger.error({
|
|
232
|
+
code: 'MC_REQ_UPLOAD_SIZE_EXCEEDED',
|
|
233
|
+
message: 'Total upload size exceeds limit',
|
|
234
|
+
requestId: $that.getRequestId(),
|
|
235
|
+
totalSize: totalUploadedSize,
|
|
236
|
+
maxSize: maxTotalSize
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
// Cleanup all uploaded files (async)
|
|
240
|
+
Promise.all(
|
|
241
|
+
uploadedFiles
|
|
242
|
+
.filter(f => f.filepath)
|
|
243
|
+
.map(f => $that.deleteFileBuffer(f.filepath))
|
|
244
|
+
).catch(() => {
|
|
245
|
+
// Cleanup errors already logged by deleteFileBuffer
|
|
122
246
|
});
|
|
123
247
|
|
|
124
248
|
reject(new Error(`Total upload size exceeds limit (${maxTotalSize} bytes)`));
|
|
@@ -134,23 +258,32 @@ class MasterRequest{
|
|
|
134
258
|
}
|
|
135
259
|
|
|
136
260
|
// CRITICAL: Log file upload for security audit trail
|
|
137
|
-
|
|
261
|
+
logger.info({
|
|
262
|
+
code: 'MC_REQ_FILE_UPLOADED',
|
|
263
|
+
message: 'File uploaded',
|
|
264
|
+
requestId: $that.getRequestId(),
|
|
265
|
+
filename: file.originalFilename || file.name,
|
|
266
|
+
size: file.size
|
|
267
|
+
});
|
|
138
268
|
});
|
|
139
269
|
|
|
140
270
|
$that.form.on('error', function(err) {
|
|
141
271
|
// CRITICAL: Handle upload errors
|
|
142
272
|
uploadAborted = true;
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
273
|
+
logger.error({
|
|
274
|
+
code: 'MC_REQ_UPLOAD_ERROR',
|
|
275
|
+
message: 'File upload error',
|
|
276
|
+
requestId: $that.getRequestId(),
|
|
277
|
+
error: err.message
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
// Cleanup temporary files (async)
|
|
281
|
+
Promise.all(
|
|
282
|
+
uploadedFiles
|
|
283
|
+
.filter(file => file.filepath)
|
|
284
|
+
.map(file => $that.deleteFileBuffer(file.filepath))
|
|
285
|
+
).catch(() => {
|
|
286
|
+
// Cleanup errors already logged by deleteFileBuffer
|
|
154
287
|
});
|
|
155
288
|
|
|
156
289
|
reject(new Error(`File upload failed: ${err.message}`));
|
|
@@ -159,17 +292,19 @@ class MasterRequest{
|
|
|
159
292
|
$that.form.on('aborted', function() {
|
|
160
293
|
// CRITICAL: Handle client abort (connection closed)
|
|
161
294
|
uploadAborted = true;
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
295
|
+
logger.warn({
|
|
296
|
+
code: 'MC_REQ_UPLOAD_ABORTED',
|
|
297
|
+
message: 'File upload aborted by client',
|
|
298
|
+
requestId: $that.getRequestId()
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
// Cleanup temporary files (async)
|
|
302
|
+
Promise.all(
|
|
303
|
+
uploadedFiles
|
|
304
|
+
.filter(file => file.filepath)
|
|
305
|
+
.map(file => $that.deleteFileBuffer(file.filepath))
|
|
306
|
+
).catch(() => {
|
|
307
|
+
// Cleanup errors already logged by deleteFileBuffer
|
|
173
308
|
});
|
|
174
309
|
|
|
175
310
|
reject(new Error('File upload aborted by client'));
|
|
@@ -187,17 +322,21 @@ class MasterRequest{
|
|
|
187
322
|
}else{
|
|
188
323
|
|
|
189
324
|
resolve($that.parsedURL);
|
|
190
|
-
|
|
325
|
+
logger.debug({
|
|
326
|
+
code: 'MC_REQ_MULTIPART_SKIPPED',
|
|
327
|
+
message: 'Multipart form-data parsing disabled',
|
|
328
|
+
requestId: $that.getRequestId()
|
|
329
|
+
});
|
|
191
330
|
}
|
|
192
331
|
break
|
|
193
|
-
case
|
|
332
|
+
case CONTENT_TYPES.JSON:
|
|
194
333
|
$that.jsonStream(request, function(data){
|
|
195
334
|
$that.parsedURL.formData = data;
|
|
196
335
|
resolve($that.parsedURL);
|
|
197
336
|
});
|
|
198
337
|
|
|
199
338
|
break
|
|
200
|
-
case
|
|
339
|
+
case CONTENT_TYPES.HTML:
|
|
201
340
|
$that.textStream(request, function(data){
|
|
202
341
|
$that.parsedURL.formData = {};
|
|
203
342
|
$that.parsedURL.formData.textField = data;
|
|
@@ -205,7 +344,7 @@ class MasterRequest{
|
|
|
205
344
|
});
|
|
206
345
|
|
|
207
346
|
break
|
|
208
|
-
case
|
|
347
|
+
case CONTENT_TYPES.PLAIN:
|
|
209
348
|
$that.fetchData(request, function(data){
|
|
210
349
|
$that.parsedURL.formData = data;
|
|
211
350
|
resolve($that.parsedURL);
|
|
@@ -216,22 +355,164 @@ class MasterRequest{
|
|
|
216
355
|
default:
|
|
217
356
|
var errorMessage = `Cannot parse - We currently support text/plain, text/html, application/json, multipart/form-data, and application/x-www-form-urlencoded - your sending us = ${contentType.type}`;
|
|
218
357
|
resolve(errorMessage);
|
|
219
|
-
|
|
358
|
+
logger.warn({
|
|
359
|
+
code: 'MC_REQ_UNSUPPORTED_CONTENT_TYPE',
|
|
360
|
+
message: 'Unsupported content type',
|
|
361
|
+
requestId: $that.getRequestId(),
|
|
362
|
+
contentType: contentType.type
|
|
363
|
+
});
|
|
220
364
|
}
|
|
221
365
|
|
|
222
366
|
}
|
|
223
367
|
else{
|
|
224
368
|
resolve($that.parsedURL);
|
|
225
369
|
}
|
|
370
|
+
} catch (error) {
|
|
371
|
+
// Catch any synchronous errors in Promise executor
|
|
372
|
+
logger.error({
|
|
373
|
+
code: 'MC_REQ_PARSE_ERROR',
|
|
374
|
+
message: 'Failed to parse request',
|
|
375
|
+
requestId: $that.getRequestId(),
|
|
376
|
+
error: error.message,
|
|
377
|
+
stack: error.stack
|
|
378
|
+
});
|
|
379
|
+
reject(new Error(`Request parsing failed: ${error.message}`));
|
|
380
|
+
}
|
|
381
|
+
}).catch((error) => {
|
|
382
|
+
// Catch any unhandled promise rejections
|
|
383
|
+
logger.error({
|
|
384
|
+
code: 'MC_REQ_UNHANDLED_REJECTION',
|
|
385
|
+
message: 'Unhandled promise rejection in getRequestParam',
|
|
386
|
+
requestId: this.getRequestId(),
|
|
387
|
+
error: error.message,
|
|
388
|
+
stack: error.stack
|
|
226
389
|
});
|
|
390
|
+
// Re-throw to propagate to caller
|
|
391
|
+
throw error;
|
|
392
|
+
});
|
|
393
|
+
};
|
|
227
394
|
|
|
395
|
+
/**
|
|
396
|
+
* Validate security-related headers and request properties
|
|
397
|
+
*
|
|
398
|
+
* Checks for common security issues:
|
|
399
|
+
* - Content-Length bomb attacks
|
|
400
|
+
* - Suspicious content-type values
|
|
401
|
+
* - Oversized headers
|
|
402
|
+
*
|
|
403
|
+
* @private
|
|
404
|
+
* @param {Request} request - HTTP request object
|
|
405
|
+
* @returns {Object} Validation result
|
|
406
|
+
* @returns {boolean} result.valid - Whether request passes validation
|
|
407
|
+
* @returns {string} result.reason - Reason for validation failure
|
|
408
|
+
*/
|
|
409
|
+
_validateSecurityHeaders(request) {
|
|
410
|
+
// Check content-length header
|
|
411
|
+
const contentLength = parseInt(request.headers['content-length'], 10);
|
|
412
|
+
if (!isNaN(contentLength) && contentLength > 200 * 1024 * 1024) { // 200MB hard limit
|
|
413
|
+
return {
|
|
414
|
+
valid: false,
|
|
415
|
+
reason: 'Content-Length exceeds maximum allowed size (200MB)'
|
|
416
|
+
};
|
|
228
417
|
}
|
|
229
|
-
|
|
230
|
-
|
|
418
|
+
|
|
419
|
+
// Check for suspiciously long header values (potential header injection)
|
|
420
|
+
for (const [key, value] of Object.entries(request.headers)) {
|
|
421
|
+
if (typeof value === 'string' && value.length > 8192) { // 8KB per header
|
|
422
|
+
return {
|
|
423
|
+
valid: false,
|
|
424
|
+
reason: `Header ${key} exceeds maximum length`
|
|
425
|
+
};
|
|
426
|
+
}
|
|
231
427
|
}
|
|
232
|
-
};
|
|
233
428
|
|
|
429
|
+
// Check for null bytes in headers (potential injection)
|
|
430
|
+
for (const [key, value] of Object.entries(request.headers)) {
|
|
431
|
+
if (typeof value === 'string' && value.includes('\0')) {
|
|
432
|
+
return {
|
|
433
|
+
valid: false,
|
|
434
|
+
reason: `Header ${key} contains null bytes`
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
return { valid: true };
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Get or generate request ID for tracing
|
|
444
|
+
*
|
|
445
|
+
* Checks for x-request-id header first, then generates a unique ID.
|
|
446
|
+
* Used for correlating logs and tracking requests through the system.
|
|
447
|
+
*
|
|
448
|
+
* @returns {string} Unique request identifier
|
|
449
|
+
* @example
|
|
450
|
+
* const reqId = this.getRequestId();
|
|
451
|
+
* logger.info({ requestId: reqId, message: 'Processing request' });
|
|
452
|
+
*/
|
|
453
|
+
getRequestId() {
|
|
454
|
+
if (!this.__requestId) {
|
|
455
|
+
// Check for existing request ID header
|
|
456
|
+
const headerReqId = this.request?.headers?.['x-request-id'];
|
|
457
|
+
if (headerReqId) {
|
|
458
|
+
this.__requestId = headerReqId;
|
|
459
|
+
} else {
|
|
460
|
+
// Generate new request ID: req_timestamp_random
|
|
461
|
+
this.__requestId = `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
return this.__requestId;
|
|
465
|
+
}
|
|
234
466
|
|
|
467
|
+
/**
|
|
468
|
+
* Handle stream errors with structured logging
|
|
469
|
+
*
|
|
470
|
+
* Centralized error handler for all stream parsing methods. Logs error
|
|
471
|
+
* and calls callback with error object.
|
|
472
|
+
*
|
|
473
|
+
* @private
|
|
474
|
+
* @param {string} code - Error code for logging
|
|
475
|
+
* @param {string} message - Human-readable error message
|
|
476
|
+
* @param {Function} callback - Callback function to invoke with error
|
|
477
|
+
* @param {Object} details - Additional error details
|
|
478
|
+
* @returns {void}
|
|
479
|
+
*/
|
|
480
|
+
_handleStreamError(code, message, callback, details = {}) {
|
|
481
|
+
logger.error({
|
|
482
|
+
code,
|
|
483
|
+
message,
|
|
484
|
+
requestId: this.getRequestId(),
|
|
485
|
+
...details
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
callback({
|
|
489
|
+
error: message,
|
|
490
|
+
...details
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Parse text/plain request body with DoS protection
|
|
496
|
+
*
|
|
497
|
+
* Implements size limits and stream error handling. Destroys request
|
|
498
|
+
* stream if payload exceeds maxBytes limit.
|
|
499
|
+
*
|
|
500
|
+
* @private
|
|
501
|
+
* @param {Request} request - HTTP request stream
|
|
502
|
+
* @param {Function} func - Callback function (data) => void
|
|
503
|
+
* @param {string|Object} func.data - Parsed text or error object
|
|
504
|
+
* @param {string} func.data.error - Error message if failed
|
|
505
|
+
* @param {number} func.data.maxSize - Maximum allowed size if exceeded
|
|
506
|
+
* @returns {void}
|
|
507
|
+
* @example
|
|
508
|
+
* this.fetchData(req, (data) => {
|
|
509
|
+
* if (data.error) {
|
|
510
|
+
* console.error('Parse failed:', data.error);
|
|
511
|
+
* } else {
|
|
512
|
+
* console.log('Text:', data);
|
|
513
|
+
* }
|
|
514
|
+
* });
|
|
515
|
+
*/
|
|
235
516
|
fetchData(request, func) {
|
|
236
517
|
|
|
237
518
|
let body = '';
|
|
@@ -249,9 +530,13 @@ class MasterRequest{
|
|
|
249
530
|
// Prevent memory overload
|
|
250
531
|
if (receivedBytes > maxBytes) {
|
|
251
532
|
errorOccurred = true;
|
|
252
|
-
request.destroy();
|
|
253
|
-
|
|
254
|
-
|
|
533
|
+
request.destroy();
|
|
534
|
+
this._handleStreamError(
|
|
535
|
+
'MC_REQ_TEXT_PAYLOAD_TOO_LARGE',
|
|
536
|
+
'Payload too large',
|
|
537
|
+
func,
|
|
538
|
+
{ receivedBytes, maxBytes, maxSize: maxBytes }
|
|
539
|
+
);
|
|
255
540
|
return;
|
|
256
541
|
}
|
|
257
542
|
|
|
@@ -267,8 +552,12 @@ class MasterRequest{
|
|
|
267
552
|
const responseData = body;
|
|
268
553
|
func(responseData);
|
|
269
554
|
} catch (err) {
|
|
270
|
-
|
|
271
|
-
|
|
555
|
+
this._handleStreamError(
|
|
556
|
+
'MC_REQ_TEXT_PROCESSING_ERROR',
|
|
557
|
+
'Error processing text/plain',
|
|
558
|
+
func,
|
|
559
|
+
{ error: err.message }
|
|
560
|
+
);
|
|
272
561
|
}
|
|
273
562
|
|
|
274
563
|
});
|
|
@@ -276,32 +565,107 @@ class MasterRequest{
|
|
|
276
565
|
request.on('error', (err) => {
|
|
277
566
|
if (errorOccurred) return;
|
|
278
567
|
errorOccurred = true;
|
|
279
|
-
|
|
280
|
-
|
|
568
|
+
this._handleStreamError(
|
|
569
|
+
'MC_REQ_STREAM_ERROR',
|
|
570
|
+
'Stream error in fetchData',
|
|
571
|
+
func,
|
|
572
|
+
{ error: err.message }
|
|
573
|
+
);
|
|
281
574
|
});
|
|
282
575
|
|
|
283
576
|
request.on('aborted', () => {
|
|
284
577
|
if (errorOccurred) return;
|
|
285
578
|
errorOccurred = true;
|
|
286
|
-
|
|
579
|
+
logger.warn({
|
|
580
|
+
code: 'MC_REQ_ABORTED',
|
|
581
|
+
message: 'Request aborted in fetchData',
|
|
582
|
+
requestId: this.getRequestId()
|
|
583
|
+
});
|
|
287
584
|
func({ error: 'Request aborted' });
|
|
288
585
|
});
|
|
289
586
|
|
|
290
587
|
} catch (error) {
|
|
291
|
-
|
|
292
|
-
|
|
588
|
+
this._handleStreamError(
|
|
589
|
+
'MC_REQ_FETCH_FAILED',
|
|
590
|
+
'Failed to fetch data',
|
|
591
|
+
func,
|
|
592
|
+
{ error: error.message }
|
|
593
|
+
);
|
|
293
594
|
}
|
|
294
595
|
}
|
|
295
596
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
597
|
+
/**
|
|
598
|
+
* Delete temporary file buffer (async operation)
|
|
599
|
+
*
|
|
600
|
+
* Used to cleanup uploaded files after processing or on error.
|
|
601
|
+
* Logs success/failure via structured logger. Uses fs.promises for
|
|
602
|
+
* proper error propagation.
|
|
603
|
+
*
|
|
604
|
+
* @param {string} filePath - Absolute path to temporary file
|
|
605
|
+
* @returns {Promise<void>}
|
|
606
|
+
* @throws {Error} If file deletion fails (error is logged but not thrown)
|
|
607
|
+
* @example
|
|
608
|
+
* await this.deleteFileBuffer('/tmp/upload-12345.tmp');
|
|
609
|
+
* // Or with error handling:
|
|
610
|
+
* try {
|
|
611
|
+
* await this.deleteFileBuffer(filePath);
|
|
612
|
+
* } catch (err) {
|
|
613
|
+
* console.error('Cleanup failed:', err);
|
|
614
|
+
* }
|
|
615
|
+
*/
|
|
616
|
+
async deleteFileBuffer(filePath){
|
|
617
|
+
// Input validation
|
|
618
|
+
if (!filePath || typeof filePath !== 'string') {
|
|
619
|
+
logger.error({
|
|
620
|
+
code: 'MC_REQ_INVALID_FILE_PATH',
|
|
621
|
+
message: 'deleteFileBuffer() requires a valid file path string',
|
|
622
|
+
requestId: this.getRequestId(),
|
|
623
|
+
filePath
|
|
624
|
+
});
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
try {
|
|
629
|
+
await fsPromises.unlink(filePath);
|
|
630
|
+
logger.debug({
|
|
631
|
+
code: 'MC_REQ_FILE_DELETED',
|
|
632
|
+
message: 'Temporary file deleted',
|
|
633
|
+
requestId: this.getRequestId(),
|
|
634
|
+
filePath
|
|
635
|
+
});
|
|
636
|
+
} catch (err) {
|
|
637
|
+
logger.error({
|
|
638
|
+
code: 'MC_REQ_FILE_DELETE_FAILED',
|
|
639
|
+
message: 'Failed to delete temporary file',
|
|
640
|
+
requestId: this.getRequestId(),
|
|
641
|
+
filePath,
|
|
642
|
+
error: err.message
|
|
643
|
+
});
|
|
644
|
+
// Don't throw - cleanup failures shouldn't break the request
|
|
645
|
+
}
|
|
303
646
|
}
|
|
304
647
|
|
|
648
|
+
/**
|
|
649
|
+
* Parse application/x-www-form-urlencoded request body
|
|
650
|
+
*
|
|
651
|
+
* Uses StringDecoder for proper UTF-8 handling. Includes DoS protection
|
|
652
|
+
* via configurable size limits. Preserves raw body string for webhook
|
|
653
|
+
* signature verification (accessible via data._rawBody).
|
|
654
|
+
*
|
|
655
|
+
* @private
|
|
656
|
+
* @param {Request} request - HTTP request stream
|
|
657
|
+
* @param {Function} func - Callback function (data) => void
|
|
658
|
+
* @param {Object} func.data - Parsed form data object or error object
|
|
659
|
+
* @param {string} func.data._rawBody - Original raw body string
|
|
660
|
+
* @param {string} func.data.error - Error message if failed
|
|
661
|
+
* @param {number} func.data.maxSize - Maximum allowed size if exceeded
|
|
662
|
+
* @returns {void}
|
|
663
|
+
* @example
|
|
664
|
+
* this.urlEncodeStream(req, (data) => {
|
|
665
|
+
* console.log(data.username, data.password);
|
|
666
|
+
* // Verify webhook signature using data._rawBody
|
|
667
|
+
* });
|
|
668
|
+
*/
|
|
305
669
|
urlEncodeStream(request, func){
|
|
306
670
|
const decoder = new StringDecoder('utf-8');
|
|
307
671
|
let buffer = '';
|
|
@@ -318,8 +682,12 @@ class MasterRequest{
|
|
|
318
682
|
if (receivedBytes > maxBytes) {
|
|
319
683
|
errorOccurred = true;
|
|
320
684
|
request.destroy();
|
|
321
|
-
|
|
322
|
-
|
|
685
|
+
this._handleStreamError(
|
|
686
|
+
'MC_REQ_FORM_DATA_TOO_LARGE',
|
|
687
|
+
'Payload too large',
|
|
688
|
+
func,
|
|
689
|
+
{ receivedBytes, maxBytes, maxSize: maxBytes }
|
|
690
|
+
);
|
|
323
691
|
return;
|
|
324
692
|
}
|
|
325
693
|
|
|
@@ -330,7 +698,7 @@ class MasterRequest{
|
|
|
330
698
|
if (errorOccurred) return;
|
|
331
699
|
|
|
332
700
|
buffer += decoder.end();
|
|
333
|
-
|
|
701
|
+
const buff = qs.parse(buffer);
|
|
334
702
|
// Preserve raw body for signature verification
|
|
335
703
|
buff._rawBody = buffer;
|
|
336
704
|
func(buff);
|
|
@@ -339,19 +707,56 @@ class MasterRequest{
|
|
|
339
707
|
request.on('error', (err) => {
|
|
340
708
|
if (errorOccurred) return;
|
|
341
709
|
errorOccurred = true;
|
|
342
|
-
|
|
343
|
-
|
|
710
|
+
this._handleStreamError(
|
|
711
|
+
'MC_REQ_STREAM_ERROR',
|
|
712
|
+
'Stream error in urlEncodeStream',
|
|
713
|
+
func,
|
|
714
|
+
{ error: err.message }
|
|
715
|
+
);
|
|
344
716
|
});
|
|
345
717
|
|
|
346
718
|
request.on('aborted', () => {
|
|
347
719
|
if (errorOccurred) return;
|
|
348
720
|
errorOccurred = true;
|
|
349
|
-
|
|
721
|
+
logger.warn({
|
|
722
|
+
code: 'MC_REQ_ABORTED',
|
|
723
|
+
message: 'Request aborted in urlEncodeStream',
|
|
724
|
+
requestId: this.getRequestId()
|
|
725
|
+
});
|
|
350
726
|
func({ error: 'Request aborted' });
|
|
351
727
|
});
|
|
352
728
|
|
|
353
729
|
}
|
|
354
730
|
|
|
731
|
+
/**
|
|
732
|
+
* Parse application/json request body
|
|
733
|
+
*
|
|
734
|
+
* Includes DoS protection via size limits. Handles empty bodies gracefully.
|
|
735
|
+
* Preserves raw body string for webhook signature verification (required
|
|
736
|
+
* by Stripe, GitHub, Shopify, etc. for HMAC validation).
|
|
737
|
+
*
|
|
738
|
+
* SECURITY: Does NOT fallback to qs.parse to prevent prototype pollution.
|
|
739
|
+
*
|
|
740
|
+
* @private
|
|
741
|
+
* @param {Request} request - HTTP request stream
|
|
742
|
+
* @param {Function} func - Callback function (data) => void
|
|
743
|
+
* @param {Object} func.data - Parsed JSON object or error object
|
|
744
|
+
* @param {string} func.data._rawBody - Original raw body string
|
|
745
|
+
* @param {string} func.data.error - Error message if failed
|
|
746
|
+
* @param {string} func.data.details - Error details if JSON parsing failed
|
|
747
|
+
* @param {number} func.data.maxSize - Maximum allowed size if exceeded
|
|
748
|
+
* @returns {void}
|
|
749
|
+
* @example
|
|
750
|
+
* this.jsonStream(req, (data) => {
|
|
751
|
+
* if (data.error) {
|
|
752
|
+
* res.statusCode = 400;
|
|
753
|
+
* res.end(JSON.stringify({ error: data.details }));
|
|
754
|
+
* } else {
|
|
755
|
+
* console.log('Received:', data);
|
|
756
|
+
* // Verify signature: hmac(data._rawBody, secret)
|
|
757
|
+
* }
|
|
758
|
+
* });
|
|
759
|
+
*/
|
|
355
760
|
jsonStream(request, func){
|
|
356
761
|
let buffer = '';
|
|
357
762
|
let receivedBytes = 0;
|
|
@@ -367,8 +772,12 @@ class MasterRequest{
|
|
|
367
772
|
if (receivedBytes > maxBytes) {
|
|
368
773
|
errorOccurred = true;
|
|
369
774
|
request.destroy();
|
|
370
|
-
|
|
371
|
-
|
|
775
|
+
this._handleStreamError(
|
|
776
|
+
'MC_REQ_JSON_PAYLOAD_TOO_LARGE',
|
|
777
|
+
'JSON payload too large',
|
|
778
|
+
func,
|
|
779
|
+
{ receivedBytes, maxBytes, maxSize: maxBytes }
|
|
780
|
+
);
|
|
372
781
|
return;
|
|
373
782
|
}
|
|
374
783
|
|
|
@@ -385,7 +794,7 @@ class MasterRequest{
|
|
|
385
794
|
}
|
|
386
795
|
|
|
387
796
|
try {
|
|
388
|
-
|
|
797
|
+
const buff = JSON.parse(buffer);
|
|
389
798
|
// IMPORTANT: Preserve raw body for webhook signature verification
|
|
390
799
|
// Many webhook providers (Stripe, GitHub, Shopify, etc.) require the
|
|
391
800
|
// exact raw body string to verify HMAC signatures
|
|
@@ -393,27 +802,62 @@ class MasterRequest{
|
|
|
393
802
|
func(buff);
|
|
394
803
|
} catch (e) {
|
|
395
804
|
// Security: Don't fallback to qs.parse to avoid prototype pollution
|
|
396
|
-
|
|
397
|
-
|
|
805
|
+
this._handleStreamError(
|
|
806
|
+
'MC_REQ_INVALID_JSON',
|
|
807
|
+
'Invalid JSON',
|
|
808
|
+
func,
|
|
809
|
+
{ details: e.message, error: e.message }
|
|
810
|
+
);
|
|
398
811
|
}
|
|
399
812
|
});
|
|
400
813
|
|
|
401
814
|
request.on('error', (err) => {
|
|
402
815
|
if (errorOccurred) return;
|
|
403
816
|
errorOccurred = true;
|
|
404
|
-
|
|
405
|
-
|
|
817
|
+
this._handleStreamError(
|
|
818
|
+
'MC_REQ_STREAM_ERROR',
|
|
819
|
+
'Stream error in jsonStream',
|
|
820
|
+
func,
|
|
821
|
+
{ error: err.message }
|
|
822
|
+
);
|
|
406
823
|
});
|
|
407
824
|
|
|
408
825
|
request.on('aborted', () => {
|
|
409
826
|
if (errorOccurred) return;
|
|
410
827
|
errorOccurred = true;
|
|
411
|
-
|
|
828
|
+
logger.warn({
|
|
829
|
+
code: 'MC_REQ_ABORTED',
|
|
830
|
+
message: 'Request aborted in jsonStream',
|
|
831
|
+
requestId: this.getRequestId()
|
|
832
|
+
});
|
|
412
833
|
func({ error: 'Request aborted' });
|
|
413
834
|
});
|
|
414
835
|
|
|
415
836
|
}
|
|
416
837
|
|
|
838
|
+
/**
|
|
839
|
+
* Parse text/html request body
|
|
840
|
+
*
|
|
841
|
+
* Uses StringDecoder for proper UTF-8 handling. Includes DoS protection
|
|
842
|
+
* via configurable size limits. Returns raw string (no parsing).
|
|
843
|
+
*
|
|
844
|
+
* @private
|
|
845
|
+
* @param {Request} request - HTTP request stream
|
|
846
|
+
* @param {Function} func - Callback function (data) => void
|
|
847
|
+
* @param {string|Object} func.data - Raw text string or error object
|
|
848
|
+
* @param {string} func.data.error - Error message if failed
|
|
849
|
+
* @param {number} func.data.maxSize - Maximum allowed size if exceeded
|
|
850
|
+
* @returns {void}
|
|
851
|
+
* @example
|
|
852
|
+
* this.textStream(req, (html) => {
|
|
853
|
+
* if (html.error) {
|
|
854
|
+
* res.statusCode = 413;
|
|
855
|
+
* res.end('Payload too large');
|
|
856
|
+
* } else {
|
|
857
|
+
* console.log('HTML:', html);
|
|
858
|
+
* }
|
|
859
|
+
* });
|
|
860
|
+
*/
|
|
417
861
|
textStream(request, func){
|
|
418
862
|
const decoder = new StringDecoder('utf-8');
|
|
419
863
|
let buffer = '';
|
|
@@ -430,8 +874,12 @@ class MasterRequest{
|
|
|
430
874
|
if (receivedBytes > maxBytes) {
|
|
431
875
|
errorOccurred = true;
|
|
432
876
|
request.destroy();
|
|
433
|
-
|
|
434
|
-
|
|
877
|
+
this._handleStreamError(
|
|
878
|
+
'MC_REQ_TEXT_PAYLOAD_TOO_LARGE',
|
|
879
|
+
'Text payload too large',
|
|
880
|
+
func,
|
|
881
|
+
{ receivedBytes, maxBytes, maxSize: maxBytes }
|
|
882
|
+
);
|
|
435
883
|
return;
|
|
436
884
|
}
|
|
437
885
|
|
|
@@ -447,22 +895,51 @@ class MasterRequest{
|
|
|
447
895
|
request.on('error', (err) => {
|
|
448
896
|
if (errorOccurred) return;
|
|
449
897
|
errorOccurred = true;
|
|
450
|
-
|
|
451
|
-
|
|
898
|
+
this._handleStreamError(
|
|
899
|
+
'MC_REQ_STREAM_ERROR',
|
|
900
|
+
'Stream error in textStream',
|
|
901
|
+
func,
|
|
902
|
+
{ error: err.message }
|
|
903
|
+
);
|
|
452
904
|
});
|
|
453
905
|
|
|
454
906
|
request.on('aborted', () => {
|
|
455
907
|
if (errorOccurred) return;
|
|
456
908
|
errorOccurred = true;
|
|
457
|
-
|
|
909
|
+
logger.warn({
|
|
910
|
+
code: 'MC_REQ_ABORTED',
|
|
911
|
+
message: 'Request aborted in textStream',
|
|
912
|
+
requestId: this.getRequestId()
|
|
913
|
+
});
|
|
458
914
|
func({ error: 'Request aborted' });
|
|
459
915
|
});
|
|
460
916
|
|
|
461
917
|
}
|
|
462
918
|
|
|
463
|
-
|
|
919
|
+
/**
|
|
920
|
+
* Clear parsed request data and close response
|
|
921
|
+
*
|
|
922
|
+
* Resets parsedURL object and delegates to MasterAction.close() to send
|
|
923
|
+
* final response with appropriate content-type and status code.
|
|
924
|
+
*
|
|
925
|
+
* @param {number} code - HTTP status code
|
|
926
|
+
* @param {string} end - Response body content
|
|
927
|
+
* @returns {void}
|
|
928
|
+
* @example
|
|
929
|
+
* this.clear(200, 'OK');
|
|
930
|
+
* this.clear(404, JSON.stringify({ error: 'Not Found' }));
|
|
931
|
+
*/
|
|
464
932
|
clear(code, end){
|
|
465
|
-
|
|
933
|
+
// Input validation
|
|
934
|
+
if (typeof code !== 'number' || code < 100 || code > 599) {
|
|
935
|
+
throw new TypeError('clear() code must be a valid HTTP status code (100-599)');
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
if (end === undefined || end === null) {
|
|
939
|
+
throw new TypeError('clear() end parameter is required');
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
this.parsedURL = {};
|
|
466
943
|
this._master.action.close(this.response, code, contentTypeManager.parse(this.request), end);
|
|
467
944
|
}
|
|
468
945
|
}
|