mastercontroller 1.3.14 → 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 -38
- 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/MasterAction.js
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
|
|
2
2
|
// version 0.0.23
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
var tools = new toolClass();
|
|
4
|
+
const toolClass = require('./MasterTools');
|
|
5
|
+
const tools = new toolClass();
|
|
7
6
|
// View templating removed - handled by view engine (e.g., MasterView)
|
|
8
7
|
|
|
9
8
|
// Node utils
|
|
10
|
-
|
|
9
|
+
const path = require('path');
|
|
11
10
|
|
|
12
11
|
// SSR runtime removed - handled by view engine
|
|
13
12
|
|
|
@@ -21,8 +20,22 @@ const { generateCSRFToken, validateCSRFToken } = require('./security/SecurityMid
|
|
|
21
20
|
const { validator, validateRequestBody, sanitizeObject } = require('./security/MasterValidator');
|
|
22
21
|
const { sanitizeUserHTML, escapeHTML } = require('./security/MasterSanitizer');
|
|
23
22
|
|
|
23
|
+
// HTTP Status Code Constants
|
|
24
|
+
const HTTP_STATUS = {
|
|
25
|
+
OK: 200,
|
|
26
|
+
REDIRECT: 302,
|
|
27
|
+
BAD_REQUEST: 400,
|
|
28
|
+
FORBIDDEN: 403,
|
|
29
|
+
NOT_FOUND: 404,
|
|
30
|
+
PAYLOAD_TOO_LARGE: 413,
|
|
31
|
+
INTERNAL_ERROR: 500
|
|
32
|
+
};
|
|
33
|
+
|
|
24
34
|
class MasterAction{
|
|
25
35
|
|
|
36
|
+
// Maximum response size (10MB default)
|
|
37
|
+
static MAX_RESPONSE_SIZE = 10 * 1024 * 1024;
|
|
38
|
+
|
|
26
39
|
// Lazy-load master to avoid circular dependency
|
|
27
40
|
// Static getter ensures single instance (Singleton pattern - Google style)
|
|
28
41
|
static get _master() {
|
|
@@ -34,20 +47,73 @@ class MasterAction{
|
|
|
34
47
|
|
|
35
48
|
// getView() removed - handled by view engine (register via master.useView())
|
|
36
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Check if response is ready for writing (headers not sent)
|
|
52
|
+
* @private
|
|
53
|
+
* @returns {boolean} True if safe to write response
|
|
54
|
+
*/
|
|
55
|
+
_isResponseReady() {
|
|
56
|
+
// Try primary response object first
|
|
57
|
+
if (this.__response) {
|
|
58
|
+
return !this.__response._headerSent && !this.__response.headersSent;
|
|
59
|
+
}
|
|
60
|
+
// Try request object response
|
|
61
|
+
if (this.__requestObject && this.__requestObject.response) {
|
|
62
|
+
const resp = this.__requestObject.response;
|
|
63
|
+
return !resp._headerSent && !resp.headersSent;
|
|
64
|
+
}
|
|
65
|
+
// No response object yet (early lifecycle)
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
37
68
|
|
|
69
|
+
/**
|
|
70
|
+
* Returns a JSON response to the client
|
|
71
|
+
* @param {Object|Array|string|number|boolean} data - Data to serialize as JSON
|
|
72
|
+
* @returns {void}
|
|
73
|
+
* @throws {Error} If JSON serialization fails or response already sent
|
|
74
|
+
* @example
|
|
75
|
+
* this.returnJson({ success: true, data: users });
|
|
76
|
+
*/
|
|
38
77
|
returnJson(data){
|
|
39
78
|
try {
|
|
40
|
-
|
|
41
|
-
// FIXED: Check both _headerSent and headersSent for compatibility
|
|
42
|
-
if (!this.__response._headerSent && !this.__response.headersSent) {
|
|
43
|
-
this.__response.writeHead(200, {'Content-Type': 'application/json'});
|
|
44
|
-
this.__response.end(json);
|
|
45
|
-
} else {
|
|
79
|
+
if (!this._isResponseReady()) {
|
|
46
80
|
logger.warn({
|
|
47
81
|
code: 'MC_WARN_HEADERS_SENT',
|
|
48
82
|
message: 'Attempted to send JSON but headers already sent'
|
|
49
83
|
});
|
|
84
|
+
return;
|
|
50
85
|
}
|
|
86
|
+
|
|
87
|
+
// Detect circular references
|
|
88
|
+
const seen = new WeakSet();
|
|
89
|
+
const json = JSON.stringify(data, (key, value) => {
|
|
90
|
+
if (typeof value === 'object' && value !== null) {
|
|
91
|
+
if (seen.has(value)) {
|
|
92
|
+
return '[Circular Reference]';
|
|
93
|
+
}
|
|
94
|
+
seen.add(value);
|
|
95
|
+
}
|
|
96
|
+
return value;
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// Check response size
|
|
100
|
+
const byteSize = Buffer.byteLength(json, 'utf8');
|
|
101
|
+
if (byteSize > MasterAction.MAX_RESPONSE_SIZE) {
|
|
102
|
+
logger.error({
|
|
103
|
+
code: 'MC_ERR_RESPONSE_TOO_LARGE',
|
|
104
|
+
message: 'JSON response exceeds maximum size',
|
|
105
|
+
size: byteSize,
|
|
106
|
+
maxSize: MasterAction.MAX_RESPONSE_SIZE
|
|
107
|
+
});
|
|
108
|
+
this.returnError(HTTP_STATUS.PAYLOAD_TOO_LARGE, 'Response payload too large');
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
this.__response.writeHead(HTTP_STATUS.OK, {
|
|
113
|
+
'Content-Type': 'application/json',
|
|
114
|
+
'Content-Length': byteSize
|
|
115
|
+
});
|
|
116
|
+
this.__response.end(json);
|
|
51
117
|
} catch (error) {
|
|
52
118
|
logger.error({
|
|
53
119
|
code: 'MC_ERR_JSON_SEND',
|
|
@@ -55,57 +121,145 @@ class MasterAction{
|
|
|
55
121
|
error: error.message,
|
|
56
122
|
stack: error.stack
|
|
57
123
|
});
|
|
124
|
+
|
|
125
|
+
// Attempt to send error response if possible
|
|
126
|
+
if (this._isResponseReady()) {
|
|
127
|
+
this.returnError(HTTP_STATUS.INTERNAL_ERROR, 'Internal server error');
|
|
128
|
+
}
|
|
58
129
|
}
|
|
59
130
|
}
|
|
60
131
|
|
|
61
132
|
// returnPartialView() removed - handled by view engine (register via master.useView())
|
|
62
133
|
|
|
134
|
+
/**
|
|
135
|
+
* Redirects to the previous page (HTTP referer) with fallback
|
|
136
|
+
* @param {string} [fallback='/'] - Fallback URL if referer is invalid
|
|
137
|
+
* @returns {void}
|
|
138
|
+
* @security Only allows same-origin redirects to prevent open redirect attacks
|
|
139
|
+
* @example
|
|
140
|
+
* this.redirectBack('/home'); // Fallback to /home if referer invalid
|
|
141
|
+
*/
|
|
63
142
|
redirectBack(fallback){
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
143
|
+
const referer = this.__requestObject.request.headers.referer || "";
|
|
144
|
+
|
|
145
|
+
// Validate referer is same-origin or allowed domain
|
|
146
|
+
if (referer && this._isValidRedirectUrl(referer)) {
|
|
147
|
+
this.redirectTo(referer);
|
|
148
|
+
} else if (fallback !== undefined) {
|
|
69
149
|
this.redirectTo(fallback);
|
|
150
|
+
} else {
|
|
151
|
+
this.redirectTo("/");
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Validate URL is safe for redirect (same-origin only)
|
|
157
|
+
* @private
|
|
158
|
+
* @param {string} url - URL to validate
|
|
159
|
+
* @returns {boolean} True if URL is safe for redirect
|
|
160
|
+
*/
|
|
161
|
+
_isValidRedirectUrl(url) {
|
|
162
|
+
try {
|
|
163
|
+
const urlObj = new URL(url, `http://${this.__requestObject.request.headers.host}`);
|
|
164
|
+
const requestHost = this.__requestObject.request.headers.host;
|
|
165
|
+
|
|
166
|
+
// Only allow same-origin redirects
|
|
167
|
+
return urlObj.host === requestHost;
|
|
168
|
+
} catch (e) {
|
|
169
|
+
return false;
|
|
70
170
|
}
|
|
71
171
|
}
|
|
72
172
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
173
|
+
/**
|
|
174
|
+
* Validate URL is safe and properly formatted for redirect
|
|
175
|
+
* @private
|
|
176
|
+
* @param {string} url - URL to validate
|
|
177
|
+
* @throws {Error} If URL is invalid or dangerous
|
|
178
|
+
*/
|
|
179
|
+
_validateRedirectUrl(url) {
|
|
180
|
+
if (!url || typeof url !== 'string') {
|
|
181
|
+
throw new Error('Invalid redirect URL: must be non-empty string');
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Prevent protocol-relative URLs (//evil.com)
|
|
185
|
+
if (url.startsWith('//')) {
|
|
186
|
+
throw new Error('Invalid redirect URL: protocol-relative URLs not allowed');
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Prevent javascript: or data: URLs
|
|
190
|
+
if (/^(javascript|data|vbscript|file):/i.test(url)) {
|
|
191
|
+
throw new Error('Invalid redirect URL: dangerous protocol detected');
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return true;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Redirects to another route via HTTP 302
|
|
199
|
+
* @param {string} url - Target URL path
|
|
200
|
+
* @param {Object} [obj={}] - Optional parameters (id goes to path, others to query string)
|
|
201
|
+
* @returns {void}
|
|
202
|
+
* @security All parameters are URL-encoded to prevent injection
|
|
203
|
+
* @example
|
|
204
|
+
* this.redirectTo('/users', { id: 5, filter: 'active' }); // → /users/5?filter=active
|
|
205
|
+
*/
|
|
206
|
+
redirectTo(url, obj = {}) {
|
|
207
|
+
try {
|
|
208
|
+
this._validateRedirectUrl(url);
|
|
209
|
+
|
|
210
|
+
const parseUrl = url.replace(/\/$/, "");
|
|
211
|
+
const queryParams = [];
|
|
212
|
+
let idParam = null;
|
|
213
|
+
|
|
214
|
+
for (const key in obj) {
|
|
215
|
+
if (obj.hasOwnProperty(key)) {
|
|
216
|
+
// Encode all values to prevent injection
|
|
217
|
+
const encodedValue = encodeURIComponent(String(obj[key]));
|
|
218
|
+
|
|
219
|
+
if (key === "id") {
|
|
220
|
+
idParam = encodedValue;
|
|
221
|
+
} else {
|
|
222
|
+
queryParams.push(`${encodeURIComponent(key)}=${encodedValue}`);
|
|
88
223
|
}
|
|
89
|
-
queryString = queryString + key + "=" + obj[key]; //?james=dfdfd&queryString
|
|
90
224
|
}
|
|
91
|
-
//?james=dfdfd&rih=sdsd&
|
|
92
225
|
}
|
|
93
|
-
};
|
|
94
226
|
|
|
95
|
-
|
|
227
|
+
let finalUrl = parseUrl;
|
|
228
|
+
if (idParam) {
|
|
229
|
+
finalUrl = `${finalUrl}/${idParam}`;
|
|
230
|
+
}
|
|
231
|
+
if (queryParams.length > 0) {
|
|
232
|
+
finalUrl = `${finalUrl}?${queryParams.join('&')}`;
|
|
233
|
+
}
|
|
96
234
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
235
|
+
if (this._isResponseReady()) {
|
|
236
|
+
this.__requestObject.response.writeHead(HTTP_STATUS.REDIRECT, {
|
|
237
|
+
'Location': finalUrl
|
|
238
|
+
});
|
|
239
|
+
this.__requestObject.response.end();
|
|
240
|
+
}
|
|
241
|
+
} catch (error) {
|
|
242
|
+
logger.error({
|
|
243
|
+
code: 'MC_ERR_INVALID_REDIRECT',
|
|
244
|
+
message: error.message,
|
|
245
|
+
url
|
|
101
246
|
});
|
|
102
|
-
this.
|
|
247
|
+
this.returnError(HTTP_STATUS.BAD_REQUEST, 'Invalid redirect URL');
|
|
103
248
|
}
|
|
104
|
-
|
|
105
249
|
}
|
|
106
250
|
|
|
107
251
|
|
|
108
|
-
|
|
252
|
+
/**
|
|
253
|
+
* Internal redirect to another controller action (no HTTP redirect)
|
|
254
|
+
* @param {string} namespace - Controller name
|
|
255
|
+
* @param {string} action - Action/method name
|
|
256
|
+
* @param {string} type - Request type (GET, POST, etc.)
|
|
257
|
+
* @param {Object} data - Parameters to pass
|
|
258
|
+
* @param {boolean} [components=false] - Whether this is a component controller
|
|
259
|
+
* @returns {void}
|
|
260
|
+
* @example
|
|
261
|
+
* this.redirectToAction('users', 'show', 'GET', { id: 5 });
|
|
262
|
+
*/
|
|
109
263
|
redirectToAction(namespace, action, type, data, components){
|
|
110
264
|
// FIXED: Declare variables before if/else to avoid undefined reference
|
|
111
265
|
const resp = this.__requestObject.response;
|
|
@@ -147,33 +301,46 @@ class MasterAction{
|
|
|
147
301
|
|
|
148
302
|
// returnView() removed - handled by view engine (register via master.useView())
|
|
149
303
|
|
|
304
|
+
/**
|
|
305
|
+
* Close HTTP response with specified content
|
|
306
|
+
* @param {Object} response - HTTP response object
|
|
307
|
+
* @param {number} code - HTTP status code
|
|
308
|
+
* @param {Object} content - Content configuration with type property
|
|
309
|
+
* @param {string} end - Response body content
|
|
310
|
+
* @returns {void}
|
|
311
|
+
* @example
|
|
312
|
+
* this.close(response, 200, { type: { 'Content-Type': 'text/html' } }, '<h1>Hello</h1>');
|
|
313
|
+
*/
|
|
150
314
|
close(response, code, content, end){
|
|
151
315
|
response.writeHead(code, content.type);
|
|
152
316
|
response.end(end);
|
|
153
317
|
}
|
|
154
318
|
|
|
155
|
-
|
|
156
|
-
|
|
319
|
+
/**
|
|
320
|
+
* Utility method to check if response is ready for writing
|
|
321
|
+
* @returns {boolean} True if safe to continue, false if response already sent
|
|
322
|
+
* @deprecated Use _isResponseReady() instead
|
|
323
|
+
*/
|
|
157
324
|
waitUntilReady(){
|
|
158
|
-
|
|
159
|
-
if (this.__response) {
|
|
160
|
-
return !this.__response._headerSent;
|
|
161
|
-
}
|
|
162
|
-
// Check request object response as fallback (matches existing redirectTo pattern)
|
|
163
|
-
if (this.__requestObject && this.__requestObject.response) {
|
|
164
|
-
return !this.__requestObject.response._headerSent;
|
|
165
|
-
}
|
|
166
|
-
// If neither exists, assume it's safe to continue (early in request lifecycle)
|
|
167
|
-
return true;
|
|
325
|
+
return this._isResponseReady();
|
|
168
326
|
}
|
|
169
327
|
|
|
170
|
-
|
|
328
|
+
/**
|
|
329
|
+
* Safe version of returnJson that checks readiness first
|
|
330
|
+
* @param {Object|Array|string|number|boolean} data - Data to serialize as JSON
|
|
331
|
+
* @returns {boolean} True if sent successfully, false if headers already sent
|
|
332
|
+
* @example
|
|
333
|
+
* if (!this.safeReturnJson({ data })) { logger.warn('Response already sent'); }
|
|
334
|
+
*/
|
|
171
335
|
safeReturnJson(data){
|
|
172
336
|
if (this.waitUntilReady()) {
|
|
173
337
|
this.returnJson(data);
|
|
174
338
|
return true;
|
|
175
339
|
}
|
|
176
|
-
|
|
340
|
+
logger.warn({
|
|
341
|
+
code: 'MC_WARN_SAFE_RETURN_JSON_FAILED',
|
|
342
|
+
message: 'Attempted to send JSON response but headers already sent'
|
|
343
|
+
});
|
|
177
344
|
return false;
|
|
178
345
|
}
|
|
179
346
|
|
|
@@ -181,6 +348,60 @@ class MasterAction{
|
|
|
181
348
|
|
|
182
349
|
// returnWebComponent() removed - handled by view engine (register via master.useView())
|
|
183
350
|
|
|
351
|
+
// ==================== Observability Methods ====================
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Get or generate request ID for tracing
|
|
355
|
+
* @returns {string} Unique request identifier
|
|
356
|
+
* @example
|
|
357
|
+
* const reqId = this.getRequestId(); // req_1234567890_abc123
|
|
358
|
+
*/
|
|
359
|
+
getRequestId() {
|
|
360
|
+
if (!this.__requestId) {
|
|
361
|
+
this.__requestId = this.__requestObject?.headers?.['x-request-id'] ||
|
|
362
|
+
`req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
363
|
+
}
|
|
364
|
+
return this.__requestId;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Record timing metric for observability
|
|
369
|
+
* @param {string} metric - Metric name
|
|
370
|
+
* @param {number} duration - Duration in milliseconds
|
|
371
|
+
* @returns {void}
|
|
372
|
+
* @example
|
|
373
|
+
* const start = Date.now();
|
|
374
|
+
* // ... do work ...
|
|
375
|
+
* this.recordTiming('db_query', Date.now() - start);
|
|
376
|
+
*/
|
|
377
|
+
recordTiming(metric, duration) {
|
|
378
|
+
logger.info({
|
|
379
|
+
code: 'MC_METRIC_TIMING',
|
|
380
|
+
metric,
|
|
381
|
+
duration,
|
|
382
|
+
requestId: this.getRequestId(),
|
|
383
|
+
path: this.__requestObject?.pathName
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Check if request should be rate limited
|
|
389
|
+
* @returns {boolean} True if request allowed
|
|
390
|
+
* @example
|
|
391
|
+
* if (!this.checkRateLimit()) {
|
|
392
|
+
* return this.returnError(429, 'Too many requests');
|
|
393
|
+
* }
|
|
394
|
+
*/
|
|
395
|
+
checkRateLimit() {
|
|
396
|
+
// Hook for rate limiting middleware
|
|
397
|
+
if (MasterAction._master.rateLimiter) {
|
|
398
|
+
const clientIp = this.__requestObject?.request?.headers?.['x-forwarded-for'] ||
|
|
399
|
+
this.__requestObject?.request?.connection?.remoteAddress;
|
|
400
|
+
return MasterAction._master.rateLimiter.check(clientIp, this.__requestObject?.pathName);
|
|
401
|
+
}
|
|
402
|
+
return true;
|
|
403
|
+
}
|
|
404
|
+
|
|
184
405
|
// ==================== Security Methods ====================
|
|
185
406
|
|
|
186
407
|
/**
|
|
@@ -322,7 +543,7 @@ class MasterAction{
|
|
|
322
543
|
code: 'MC_CONFIG_MISSING_HOSTNAME',
|
|
323
544
|
message: 'requireHTTPS called but no hostname configured in MasterAction._master.env.server.hostname'
|
|
324
545
|
});
|
|
325
|
-
this.returnError(
|
|
546
|
+
this.returnError(HTTP_STATUS.INTERNAL_ERROR, 'Server misconfiguration');
|
|
326
547
|
return false;
|
|
327
548
|
}
|
|
328
549
|
|
|
@@ -335,19 +556,38 @@ class MasterAction{
|
|
|
335
556
|
|
|
336
557
|
/**
|
|
337
558
|
* Return error response with proper status
|
|
338
|
-
*
|
|
559
|
+
* @param {number} statusCode - HTTP status code
|
|
560
|
+
* @param {string} message - Error message
|
|
561
|
+
* @param {Object} [details={}] - Additional error details
|
|
562
|
+
* @returns {void}
|
|
563
|
+
* @example
|
|
564
|
+
* this.returnError(400, 'Invalid input');
|
|
339
565
|
*/
|
|
340
566
|
returnError(statusCode, message, details = {}) {
|
|
341
|
-
|
|
567
|
+
if (this._isResponseReady()) {
|
|
568
|
+
const res = this.__response || (this.__requestObject && this.__requestObject.response);
|
|
342
569
|
|
|
343
|
-
|
|
344
|
-
res.writeHead(statusCode, { 'Content-Type': 'application/json' });
|
|
345
|
-
res.end(JSON.stringify({
|
|
570
|
+
const errorResponse = {
|
|
346
571
|
error: true,
|
|
347
572
|
statusCode,
|
|
348
573
|
message,
|
|
574
|
+
timestamp: new Date().toISOString(),
|
|
575
|
+
path: this.__requestObject?.pathName,
|
|
576
|
+
method: this.__requestObject?.request?.method,
|
|
349
577
|
...details
|
|
350
|
-
}
|
|
578
|
+
};
|
|
579
|
+
|
|
580
|
+
res.writeHead(statusCode, { 'Content-Type': 'application/json' });
|
|
581
|
+
res.end(JSON.stringify(errorResponse));
|
|
582
|
+
|
|
583
|
+
// Log error for monitoring
|
|
584
|
+
logger.error({
|
|
585
|
+
code: 'MC_ERR_CLIENT_ERROR',
|
|
586
|
+
statusCode,
|
|
587
|
+
message,
|
|
588
|
+
path: errorResponse.path,
|
|
589
|
+
method: errorResponse.method
|
|
590
|
+
});
|
|
351
591
|
}
|
|
352
592
|
}
|
|
353
593
|
|