mastercontroller 1.3.1 → 1.3.2
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 +3 -1
- package/MasterAction.js +137 -23
- package/MasterActionFilters.js +197 -92
- package/MasterControl.js +264 -45
- package/MasterHtml.js +226 -143
- package/MasterRequest.js +202 -24
- package/MasterSocket.js +6 -1
- package/MasterTools.js +388 -0
- package/README.md +2288 -369
- package/SECURITY-FIXES-v1.3.2.md +614 -0
- package/docs/SECURITY-AUDIT-ACTION-SYSTEM.md +1374 -0
- package/docs/SECURITY-AUDIT-HTTPS.md +1056 -0
- package/docs/SECURITY-QUICKSTART.md +375 -0
- package/docs/timeout-and-error-handling.md +8 -6
- package/package.json +1 -1
- package/security/SecurityEnforcement.js +241 -0
- package/security/SessionSecurity.js +3 -2
- package/test/security/filters.test.js +276 -0
- package/test/security/https.test.js +214 -0
- package/test/security/path-traversal.test.js +222 -0
- package/test/security/xss.test.js +190 -0
- package/MasterSession.js +0 -208
- package/docs/server-setup-hostname-binding.md +0 -24
- package/docs/server-setup-http.md +0 -32
- package/docs/server-setup-https-credentials.md +0 -32
- package/docs/server-setup-https-env-tls-sni.md +0 -62
- package/docs/server-setup-nginx-reverse-proxy.md +0 -46
package/MasterAction.js
CHANGED
|
@@ -46,22 +46,72 @@ class MasterAction{
|
|
|
46
46
|
|
|
47
47
|
|
|
48
48
|
returnJson(data){
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
this.__response.
|
|
49
|
+
try {
|
|
50
|
+
const json = JSON.stringify(data);
|
|
51
|
+
// FIXED: Check both _headerSent and headersSent for compatibility
|
|
52
|
+
if (!this.__response._headerSent && !this.__response.headersSent) {
|
|
53
|
+
this.__response.writeHead(200, {'Content-Type': 'application/json'});
|
|
54
|
+
this.__response.end(json);
|
|
55
|
+
} else {
|
|
56
|
+
logger.warn({
|
|
57
|
+
code: 'MC_WARN_HEADERS_SENT',
|
|
58
|
+
message: 'Attempted to send JSON but headers already sent'
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
} catch (error) {
|
|
62
|
+
logger.error({
|
|
63
|
+
code: 'MC_ERR_JSON_SEND',
|
|
64
|
+
message: 'Failed to send JSON response',
|
|
65
|
+
error: error.message,
|
|
66
|
+
stack: error.stack
|
|
67
|
+
});
|
|
53
68
|
}
|
|
54
69
|
}
|
|
55
70
|
|
|
56
71
|
// location starts from the view folder. Ex: partialViews/fileName.html
|
|
57
72
|
returnPartialView(location, data){
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
73
|
+
// SECURITY: Validate path to prevent traversal attacks
|
|
74
|
+
if (!location || location.includes('..') || location.includes('~') || path.isAbsolute(location)) {
|
|
75
|
+
logger.warn({
|
|
76
|
+
code: 'MC_SECURITY_PATH_TRAVERSAL',
|
|
77
|
+
message: 'Path traversal attempt blocked in returnPartialView',
|
|
78
|
+
path: location
|
|
79
|
+
});
|
|
80
|
+
this.returnError(400, 'Invalid path');
|
|
81
|
+
return '';
|
|
62
82
|
}
|
|
63
|
-
|
|
64
|
-
|
|
83
|
+
|
|
84
|
+
const actionUrl = path.resolve(master.root, location);
|
|
85
|
+
|
|
86
|
+
// SECURITY: Ensure resolved path is within app root
|
|
87
|
+
if (!actionUrl.startsWith(master.root)) {
|
|
88
|
+
logger.warn({
|
|
89
|
+
code: 'MC_SECURITY_PATH_TRAVERSAL',
|
|
90
|
+
message: 'Path traversal blocked in returnPartialView',
|
|
91
|
+
requestedPath: location,
|
|
92
|
+
resolvedPath: actionUrl
|
|
93
|
+
});
|
|
94
|
+
this.returnError(403, 'Forbidden');
|
|
95
|
+
return '';
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
const getAction = fileserver.readFileSync(actionUrl, 'utf8');
|
|
100
|
+
if(master.overwrite.isTemplate){
|
|
101
|
+
return master.overwrite.templateRender( data, "returnPartialView");
|
|
102
|
+
}
|
|
103
|
+
else{
|
|
104
|
+
return temp.htmlBuilder(getAction, data);
|
|
105
|
+
}
|
|
106
|
+
} catch (error) {
|
|
107
|
+
logger.error({
|
|
108
|
+
code: 'MC_ERR_PARTIAL_VIEW',
|
|
109
|
+
message: 'Failed to read partial view',
|
|
110
|
+
path: location,
|
|
111
|
+
error: error.message
|
|
112
|
+
});
|
|
113
|
+
this.returnError(404, 'View not found');
|
|
114
|
+
return '';
|
|
65
115
|
}
|
|
66
116
|
}
|
|
67
117
|
|
|
@@ -112,21 +162,34 @@ class MasterAction{
|
|
|
112
162
|
|
|
113
163
|
// redirects to another action inside the same controller = does not reload the page
|
|
114
164
|
redirectToAction(namespace, action, type, data, components){
|
|
165
|
+
// FIXED: Declare variables before if/else to avoid undefined reference
|
|
166
|
+
const resp = this.__requestObject.response;
|
|
167
|
+
const req = this.__requestObject.request;
|
|
115
168
|
|
|
116
|
-
|
|
169
|
+
const requestObj = {
|
|
117
170
|
toController : namespace,
|
|
118
171
|
toAction : action,
|
|
119
172
|
type : type,
|
|
120
173
|
params : data
|
|
121
|
-
}
|
|
174
|
+
};
|
|
175
|
+
|
|
122
176
|
if(components){
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
177
|
+
master.router.currentRoute = {
|
|
178
|
+
root : `${master.root}/components/${namespace}`,
|
|
179
|
+
toController : namespace,
|
|
180
|
+
toAction : action,
|
|
181
|
+
response : resp,
|
|
182
|
+
request: req
|
|
183
|
+
};
|
|
126
184
|
}else{
|
|
127
|
-
master.router.currentRoute = {
|
|
185
|
+
master.router.currentRoute = {
|
|
186
|
+
root : `${master.root}/${namespace}`,
|
|
187
|
+
toController : namespace,
|
|
188
|
+
toAction : action,
|
|
189
|
+
response : resp,
|
|
190
|
+
request: req
|
|
191
|
+
};
|
|
128
192
|
}
|
|
129
|
-
|
|
130
193
|
|
|
131
194
|
master.router._call(requestObj);
|
|
132
195
|
}
|
|
@@ -176,11 +239,45 @@ class MasterAction{
|
|
|
176
239
|
}
|
|
177
240
|
|
|
178
241
|
returnViewWithoutEngine(location){
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
242
|
+
// SECURITY: Validate path to prevent traversal attacks
|
|
243
|
+
if (!location || location.includes('..') || location.includes('~') || path.isAbsolute(location)) {
|
|
244
|
+
logger.warn({
|
|
245
|
+
code: 'MC_SECURITY_PATH_TRAVERSAL',
|
|
246
|
+
message: 'Path traversal attempt blocked in returnViewWithoutEngine',
|
|
247
|
+
path: location
|
|
248
|
+
});
|
|
249
|
+
this.returnError(400, 'Invalid path');
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const actionUrl = path.resolve(master.root, location);
|
|
254
|
+
|
|
255
|
+
// SECURITY: Ensure resolved path is within app root
|
|
256
|
+
if (!actionUrl.startsWith(master.root)) {
|
|
257
|
+
logger.warn({
|
|
258
|
+
code: 'MC_SECURITY_PATH_TRAVERSAL',
|
|
259
|
+
message: 'Path traversal blocked in returnViewWithoutEngine',
|
|
260
|
+
requestedPath: location,
|
|
261
|
+
resolvedPath: actionUrl
|
|
262
|
+
});
|
|
263
|
+
this.returnError(403, 'Forbidden');
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
try {
|
|
268
|
+
const masterView = fileserver.readFileSync(actionUrl, 'utf8');
|
|
269
|
+
if (!this.__requestObject.response._headerSent) {
|
|
270
|
+
this.__requestObject.response.writeHead(200, {'Content-Type': 'text/html'});
|
|
271
|
+
this.__requestObject.response.end(masterView);
|
|
272
|
+
}
|
|
273
|
+
} catch (error) {
|
|
274
|
+
logger.error({
|
|
275
|
+
code: 'MC_ERR_VIEW_READ',
|
|
276
|
+
message: 'Failed to read view file',
|
|
277
|
+
path: location,
|
|
278
|
+
error: error.message
|
|
279
|
+
});
|
|
280
|
+
this.returnError(404, 'View not found');
|
|
184
281
|
}
|
|
185
282
|
}
|
|
186
283
|
|
|
@@ -448,6 +545,7 @@ class MasterAction{
|
|
|
448
545
|
/**
|
|
449
546
|
* Require HTTPS for this action
|
|
450
547
|
* Usage: if (!this.requireHTTPS()) return;
|
|
548
|
+
* FIXED: Uses configured hostname, not unvalidated Host header
|
|
451
549
|
*/
|
|
452
550
|
requireHTTPS() {
|
|
453
551
|
if (!this.isSecure()) {
|
|
@@ -457,7 +555,23 @@ class MasterAction{
|
|
|
457
555
|
path: this.__requestObject.pathName
|
|
458
556
|
});
|
|
459
557
|
|
|
460
|
-
|
|
558
|
+
// SECURITY FIX: Never use Host header from request (open redirect vulnerability)
|
|
559
|
+
// Use configured hostname instead
|
|
560
|
+
const configuredHost = master.env?.server?.hostname || 'localhost';
|
|
561
|
+
const httpsPort = master.env?.server?.httpsPort || 443;
|
|
562
|
+
const port = httpsPort === 443 ? '' : `:${httpsPort}`;
|
|
563
|
+
|
|
564
|
+
// Validate configured host exists
|
|
565
|
+
if (!configuredHost || configuredHost === 'localhost') {
|
|
566
|
+
logger.error({
|
|
567
|
+
code: 'MC_CONFIG_MISSING_HOSTNAME',
|
|
568
|
+
message: 'requireHTTPS called but no hostname configured in master.env.server.hostname'
|
|
569
|
+
});
|
|
570
|
+
this.returnError(500, 'Server misconfiguration');
|
|
571
|
+
return false;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
const httpsUrl = `https://${configuredHost}${port}${this.__requestObject.pathName}`;
|
|
461
575
|
this.redirectTo(httpsUrl);
|
|
462
576
|
return false;
|
|
463
577
|
}
|
package/MasterActionFilters.js
CHANGED
|
@@ -1,98 +1,203 @@
|
|
|
1
|
-
// version
|
|
1
|
+
// version 2.0 - FIXED: Instance-level filters, async support, multiple filters
|
|
2
2
|
var master = require('./MasterControl');
|
|
3
|
-
|
|
4
|
-
var _beforeActionFunc = {
|
|
5
|
-
namespace : "",
|
|
6
|
-
actionList : "",
|
|
7
|
-
callBack : "",
|
|
8
|
-
that : ""
|
|
9
|
-
};
|
|
10
|
-
var _afterActionFunc = {
|
|
11
|
-
namespace : "",
|
|
12
|
-
actionList : "",
|
|
13
|
-
callBack : "",
|
|
14
|
-
that : ""
|
|
15
|
-
};
|
|
16
|
-
var emit = "";
|
|
3
|
+
const { logger } = require('./error/MasterErrorLogger');
|
|
17
4
|
|
|
18
5
|
class MasterActionFilters {
|
|
19
6
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
7
|
+
constructor() {
|
|
8
|
+
// FIXED: Instance-level storage instead of module-level
|
|
9
|
+
// Each controller gets its own filter arrays
|
|
10
|
+
this._beforeActionFilters = [];
|
|
11
|
+
this._afterActionFilters = [];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Register a before action filter
|
|
15
|
+
// FIXED: Adds to array instead of overwriting
|
|
16
|
+
beforeAction(actionlist, func){
|
|
17
|
+
if (typeof func !== 'function') {
|
|
18
|
+
master.error.log("beforeAction callback not a function", "warn");
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// FIXED: Push to array, don't overwrite
|
|
23
|
+
this._beforeActionFilters.push({
|
|
24
|
+
namespace: this.__namespace,
|
|
25
|
+
actionList: Array.isArray(actionlist) ? actionlist : [actionlist],
|
|
26
|
+
callBack: func,
|
|
27
|
+
that: this
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Register an after action filter
|
|
32
|
+
// FIXED: Adds to array instead of overwriting
|
|
33
|
+
afterAction(actionlist, func){
|
|
34
|
+
if (typeof func !== 'function') {
|
|
35
|
+
master.error.log("afterAction callback not a function", "warn");
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// FIXED: Push to array, don't overwrite
|
|
40
|
+
this._afterActionFilters.push({
|
|
41
|
+
namespace: this.__namespace,
|
|
42
|
+
actionList: Array.isArray(actionlist) ? actionlist : [actionlist],
|
|
43
|
+
callBack: func,
|
|
44
|
+
that: this
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Check if controller has before action filters for this action
|
|
49
|
+
__hasBeforeAction(obj, request){
|
|
50
|
+
if (!this._beforeActionFilters || this._beforeActionFilters.length === 0) {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return this._beforeActionFilters.some(filter => {
|
|
55
|
+
if (filter.namespace !== obj.__namespace) {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const requestAction = request.toAction.replace(/\s/g, '');
|
|
60
|
+
return filter.actionList.some(action => {
|
|
61
|
+
const filterAction = action.replace(/\s/g, '');
|
|
62
|
+
return filterAction === requestAction;
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Execute all matching before action filters
|
|
68
|
+
// FIXED: Async support, error handling, timeout protection, no variable shadowing
|
|
69
|
+
async __callBeforeAction(obj, request, emitter) {
|
|
70
|
+
if (!this._beforeActionFilters || this._beforeActionFilters.length === 0) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Find all matching filters
|
|
75
|
+
const requestAction = request.toAction.replace(/\s/g, '');
|
|
76
|
+
const matchingFilters = this._beforeActionFilters.filter(filter => {
|
|
77
|
+
if (filter.namespace !== obj.__namespace) {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return filter.actionList.some(actionName => {
|
|
82
|
+
const normalizedAction = actionName.replace(/\s/g, '');
|
|
83
|
+
return normalizedAction === requestAction;
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// Execute all matching filters in order
|
|
88
|
+
for (const filter of matchingFilters) {
|
|
89
|
+
try {
|
|
90
|
+
// FIXED: Add timeout protection (5 seconds default)
|
|
91
|
+
await this._executeWithTimeout(
|
|
92
|
+
filter.callBack,
|
|
93
|
+
filter.that,
|
|
94
|
+
[request],
|
|
95
|
+
emitter,
|
|
96
|
+
5000
|
|
97
|
+
);
|
|
98
|
+
} catch (error) {
|
|
99
|
+
// FIXED: Proper error handling
|
|
100
|
+
logger.error({
|
|
101
|
+
code: 'MC_FILTER_ERROR',
|
|
102
|
+
message: 'Error in beforeAction filter',
|
|
103
|
+
namespace: filter.namespace,
|
|
104
|
+
action: requestAction,
|
|
105
|
+
error: error.message,
|
|
106
|
+
stack: error.stack
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// Send error response
|
|
110
|
+
const res = request.response;
|
|
111
|
+
if (res && !res._headerSent && !res.headersSent) {
|
|
112
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
113
|
+
res.end(JSON.stringify({
|
|
114
|
+
error: 'Internal Server Error',
|
|
115
|
+
message: master.environmentType === 'development' ? error.message : 'Filter execution failed'
|
|
116
|
+
}));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Don't continue to other filters if one fails
|
|
120
|
+
throw error;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Execute all matching after action filters
|
|
126
|
+
// FIXED: Async support, error handling, no variable shadowing
|
|
127
|
+
async __callAfterAction(obj, request) {
|
|
128
|
+
if (!this._afterActionFilters || this._afterActionFilters.length === 0) {
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Find all matching filters
|
|
133
|
+
const requestAction = request.toAction.replace(/\s/g, '');
|
|
134
|
+
const matchingFilters = this._afterActionFilters.filter(filter => {
|
|
135
|
+
if (filter.namespace !== obj.__namespace) {
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return filter.actionList.some(actionName => {
|
|
140
|
+
const normalizedAction = actionName.replace(/\s/g, '');
|
|
141
|
+
return normalizedAction === requestAction;
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// Execute all matching filters in order
|
|
146
|
+
for (const filter of matchingFilters) {
|
|
147
|
+
try {
|
|
148
|
+
// FIXED: Add timeout protection (5 seconds default)
|
|
149
|
+
await this._executeWithTimeout(
|
|
150
|
+
filter.callBack,
|
|
151
|
+
filter.that,
|
|
152
|
+
[request],
|
|
153
|
+
null,
|
|
154
|
+
5000
|
|
155
|
+
);
|
|
156
|
+
} catch (error) {
|
|
157
|
+
// FIXED: Proper error handling
|
|
158
|
+
logger.error({
|
|
159
|
+
code: 'MC_FILTER_ERROR',
|
|
160
|
+
message: 'Error in afterAction filter',
|
|
161
|
+
namespace: filter.namespace,
|
|
162
|
+
action: requestAction,
|
|
163
|
+
error: error.message,
|
|
164
|
+
stack: error.stack
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// After filters don't stop execution, just log
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// FIXED: Execute function with timeout protection
|
|
173
|
+
async _executeWithTimeout(func, context, args, emitter, timeout) {
|
|
174
|
+
// Store emitter in context for next() call
|
|
175
|
+
if (emitter) {
|
|
176
|
+
context.__filterEmitter = emitter;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return Promise.race([
|
|
180
|
+
// Execute the filter
|
|
181
|
+
Promise.resolve(func.call(context, ...args)),
|
|
182
|
+
// Timeout promise
|
|
183
|
+
new Promise((_, reject) =>
|
|
184
|
+
setTimeout(() => reject(new Error(`Filter timeout after ${timeout}ms`)), timeout)
|
|
185
|
+
)
|
|
186
|
+
]);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// FIXED: Request-scoped next() function
|
|
190
|
+
next(){
|
|
191
|
+
if (this.__filterEmitter) {
|
|
192
|
+
this.__filterEmitter.emit("controller");
|
|
193
|
+
} else {
|
|
194
|
+
logger.warn({
|
|
195
|
+
code: 'MC_FILTER_WARN',
|
|
196
|
+
message: 'next() called but no emitter available',
|
|
197
|
+
namespace: this.__namespace
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
}
|
|
96
201
|
}
|
|
97
202
|
|
|
98
|
-
master.extendController(
|
|
203
|
+
master.extendController(MasterActionFilters);
|