millas 0.2.12-beta-2 → 0.2.13-beta
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/package.json +3 -2
- package/src/admin/Admin.js +122 -38
- package/src/admin/ViewContext.js +12 -3
- package/src/admin/resources/AdminResource.js +10 -0
- package/src/admin/static/admin.css +95 -14
- package/src/admin/views/layouts/base.njk +23 -34
- package/src/admin/views/pages/detail.njk +16 -5
- package/src/admin/views/pages/error.njk +65 -0
- package/src/admin/views/pages/list.njk +127 -2
- package/src/admin/views/partials/form-scripts.njk +7 -3
- package/src/admin/views/partials/form-widget.njk +2 -1
- package/src/admin/views/partials/icons.njk +64 -0
- package/src/ai/AIManager.js +954 -0
- package/src/ai/AITokenBudget.js +250 -0
- package/src/ai/PromptGuard.js +216 -0
- package/src/ai/agents.js +218 -0
- package/src/ai/conversation.js +213 -0
- package/src/ai/drivers.js +734 -0
- package/src/ai/files.js +249 -0
- package/src/ai/media.js +303 -0
- package/src/ai/pricing.js +152 -0
- package/src/ai/provider_tools.js +114 -0
- package/src/ai/types.js +356 -0
- package/src/commands/createsuperuser.js +17 -4
- package/src/commands/serve.js +2 -4
- package/src/container/AppInitializer.js +39 -15
- package/src/container/Application.js +31 -1
- package/src/core/foundation.js +1 -1
- package/src/errors/HttpError.js +32 -16
- package/src/facades/AI.js +411 -0
- package/src/facades/Hash.js +67 -0
- package/src/facades/Process.js +144 -0
- package/src/hashing/Hash.js +262 -0
- package/src/http/HtmlEscape.js +162 -0
- package/src/http/MillasRequest.js +63 -7
- package/src/http/MillasResponse.js +70 -4
- package/src/http/ResponseDispatcher.js +21 -27
- package/src/http/SafeFilePath.js +195 -0
- package/src/http/SafeRedirect.js +62 -0
- package/src/http/SecurityBootstrap.js +70 -0
- package/src/http/helpers.js +40 -125
- package/src/http/index.js +10 -1
- package/src/http/middleware/CsrfMiddleware.js +258 -0
- package/src/http/middleware/RateLimiter.js +314 -0
- package/src/http/middleware/SecurityHeaders.js +281 -0
- package/src/i18n/Translator.js +10 -2
- package/src/logger/LogRedactor.js +247 -0
- package/src/logger/Logger.js +1 -1
- package/src/logger/formatters/JsonFormatter.js +11 -4
- package/src/logger/formatters/PrettyFormatter.js +3 -1
- package/src/logger/formatters/SimpleFormatter.js +14 -3
- package/src/middleware/ThrottleMiddleware.js +27 -4
- package/src/process/Process.js +333 -0
- package/src/router/MiddlewareRegistry.js +27 -2
- package/src/scaffold/templates.js +3 -0
- package/src/validation/Validator.js +348 -607
- package/src/admin.zip +0 -0
|
@@ -225,13 +225,26 @@ class MillasRequest {
|
|
|
225
225
|
// ─── Validation ─────────────────────────────────────────────────────────────
|
|
226
226
|
|
|
227
227
|
/**
|
|
228
|
-
* Validate request input against rules.
|
|
229
|
-
*
|
|
228
|
+
* Validate request input against rules.
|
|
229
|
+
* Throws a 422 ValidationError on failure.
|
|
230
|
+
* Returns the validated + type-coerced data subset on success.
|
|
230
231
|
*
|
|
231
232
|
* const data = await req.validate({
|
|
232
|
-
* name:
|
|
233
|
-
* email:
|
|
234
|
-
*
|
|
233
|
+
* name: 'required|string|min:2|max:100',
|
|
234
|
+
* email: 'required|email',
|
|
235
|
+
* password: 'required|string|min:8',
|
|
236
|
+
* age: 'optional|number|min:13',
|
|
237
|
+
* });
|
|
238
|
+
*
|
|
239
|
+
* For route-level validation (runs before the handler, result in req.validated):
|
|
240
|
+
*
|
|
241
|
+
* Route.post('/register', {
|
|
242
|
+
* validate: {
|
|
243
|
+
* email: 'required|email',
|
|
244
|
+
* password: 'required|string|min:8',
|
|
245
|
+
* },
|
|
246
|
+
* }, async (req) => {
|
|
247
|
+
* const { email, password } = req.validated;
|
|
235
248
|
* });
|
|
236
249
|
*/
|
|
237
250
|
async validate(rules) {
|
|
@@ -239,15 +252,58 @@ class MillasRequest {
|
|
|
239
252
|
return Validator.validate(this.all(), rules);
|
|
240
253
|
}
|
|
241
254
|
|
|
255
|
+
/**
|
|
256
|
+
* The validated + coerced input — populated by route-level validation middleware.
|
|
257
|
+
* Null if no route-level validation was declared for this route.
|
|
258
|
+
*
|
|
259
|
+
* Route.post('/login', { validate: { email: 'required|email' } }, async (req) => {
|
|
260
|
+
* req.validated.email // guaranteed valid email string
|
|
261
|
+
* });
|
|
262
|
+
*/
|
|
263
|
+
get validated() {
|
|
264
|
+
return this._req.validated ?? null;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ─── CSRF ────────────────────────────────────────────────────────────────────
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Get the CSRF token for the current request.
|
|
271
|
+
* Use this in templates to populate the hidden _csrf field.
|
|
272
|
+
*
|
|
273
|
+
* <input type="hidden" name="_csrf" value="<%= req.csrfToken() %>">
|
|
274
|
+
*
|
|
275
|
+
* Returns an empty string if CSRF middleware is not active (e.g. API routes).
|
|
276
|
+
*/
|
|
277
|
+
csrfToken() {
|
|
278
|
+
if (typeof this._req.csrfToken === 'function') {
|
|
279
|
+
return this._req.csrfToken();
|
|
280
|
+
}
|
|
281
|
+
return '';
|
|
282
|
+
}
|
|
283
|
+
|
|
242
284
|
// ─── Escape hatch ────────────────────────────────────────────────────────────
|
|
243
285
|
|
|
244
286
|
/**
|
|
245
287
|
* The raw underlying Express request.
|
|
246
|
-
*
|
|
288
|
+
*
|
|
289
|
+
* WARNING: Accessing req.raw bypasses all Millas security abstractions
|
|
290
|
+
* (validation, CSRF, sanitization). Use only when MillasRequest genuinely
|
|
291
|
+
* does not expose what you need, and never pass req.raw values directly
|
|
292
|
+
* to database queries or HTML output without manual sanitization.
|
|
247
293
|
*/
|
|
248
294
|
get raw() {
|
|
295
|
+
if (process.env.NODE_ENV === 'development') {
|
|
296
|
+
// Help developers discover missing MillasRequest features
|
|
297
|
+
// rather than defaulting to raw access silently
|
|
298
|
+
const stack = new Error().stack?.split('\n')[2]?.trim() || '';
|
|
299
|
+
console.warn(
|
|
300
|
+
`[Millas] req.raw accessed at ${stack}. ` +
|
|
301
|
+
'If MillasRequest is missing a feature you need, consider opening an issue ' +
|
|
302
|
+
'rather than bypassing the abstraction layer.'
|
|
303
|
+
);
|
|
304
|
+
}
|
|
249
305
|
return this._req;
|
|
250
306
|
}
|
|
251
307
|
}
|
|
252
308
|
|
|
253
|
-
module.exports = MillasRequest;
|
|
309
|
+
module.exports = MillasRequest;
|
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
// Secure cookie defaults — can be overridden per-call or via config/security.js
|
|
4
|
+
// These are applied in cookie() below; callers can override any individual option.
|
|
5
|
+
let _cookieDefaults = {
|
|
6
|
+
httpOnly: true,
|
|
7
|
+
secure: process.env.NODE_ENV === 'production',
|
|
8
|
+
sameSite: 'Lax',
|
|
9
|
+
path: '/',
|
|
10
|
+
};
|
|
11
|
+
|
|
3
12
|
/**
|
|
4
13
|
* MillasResponse
|
|
5
14
|
*
|
|
@@ -97,24 +106,53 @@ class MillasResponse {
|
|
|
97
106
|
/**
|
|
98
107
|
* Set a cookie on the response.
|
|
99
108
|
*
|
|
100
|
-
*
|
|
109
|
+
* Secure defaults (httpOnly, secure, sameSite: Lax, path: /) are applied
|
|
110
|
+
* automatically. Pass explicit options to override any individual default.
|
|
111
|
+
*
|
|
112
|
+
* // Secure by default — no extra options needed:
|
|
113
|
+
* return jsonify(data).cookie('session', token)
|
|
114
|
+
*
|
|
115
|
+
* // Override individual options:
|
|
116
|
+
* return jsonify(data).cookie('token', jwt, { maxAge: 3600 })
|
|
117
|
+
*
|
|
118
|
+
* // Opt out of a default (e.g. a non-sensitive preference cookie):
|
|
119
|
+
* return jsonify(data).cookie('theme', 'dark', { httpOnly: false })
|
|
120
|
+
*
|
|
121
|
+
* // Cross-site cookie (e.g. OAuth callback) — must also set secure: true:
|
|
122
|
+
* return jsonify(data).cookie('oauth_state', state, { sameSite: 'None', secure: true })
|
|
101
123
|
*/
|
|
102
124
|
cookie(name, value, options = {}) {
|
|
125
|
+
// Merge: secure defaults < caller options
|
|
126
|
+
// This means callers can always override, but never accidentally get insecure defaults
|
|
127
|
+
const merged = { ..._cookieDefaults, ...options };
|
|
103
128
|
return new MillasResponse({
|
|
104
129
|
type: this._type,
|
|
105
130
|
body: this._body,
|
|
106
131
|
status: this._status,
|
|
107
132
|
headers: this._headers,
|
|
108
|
-
cookies: { ...this._cookies, [name]: { value, options } },
|
|
133
|
+
cookies: { ...this._cookies, [name]: { value, options: merged } },
|
|
109
134
|
});
|
|
110
135
|
}
|
|
111
136
|
|
|
112
137
|
/**
|
|
113
138
|
* Clear a cookie.
|
|
139
|
+
*
|
|
114
140
|
* return jsonify(data).clearCookie('session')
|
|
141
|
+
*
|
|
142
|
+
* Preserves the same path/domain options used when the cookie was set
|
|
143
|
+
* so the browser correctly removes it.
|
|
115
144
|
*/
|
|
116
145
|
clearCookie(name, options = {}) {
|
|
117
|
-
|
|
146
|
+
// Must match the path/domain of the original cookie for the browser to delete it.
|
|
147
|
+
// Merge defaults so path always matches.
|
|
148
|
+
const clearOpts = { ..._cookieDefaults, ...options, maxAge: 0, expires: new Date(0) };
|
|
149
|
+
return new MillasResponse({
|
|
150
|
+
type: this._type,
|
|
151
|
+
body: this._body,
|
|
152
|
+
status: this._status,
|
|
153
|
+
headers: this._headers,
|
|
154
|
+
cookies: { ...this._cookies, [name]: { value: '', options: clearOpts } },
|
|
155
|
+
});
|
|
118
156
|
}
|
|
119
157
|
|
|
120
158
|
// ─── Static factories ─────────────────────────────────────────────────────
|
|
@@ -191,6 +229,34 @@ class MillasResponse {
|
|
|
191
229
|
static isResponse(value) {
|
|
192
230
|
return value instanceof MillasResponse;
|
|
193
231
|
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Override the global cookie defaults.
|
|
235
|
+
*
|
|
236
|
+
* Called by the framework bootstrap when it loads config/security.js.
|
|
237
|
+
* Can also be called by developers for custom defaults:
|
|
238
|
+
*
|
|
239
|
+
* MillasResponse.configureCookieDefaults({
|
|
240
|
+
* httpOnly: true,
|
|
241
|
+
* secure: true,
|
|
242
|
+
* sameSite: 'Strict', // stricter than default Lax
|
|
243
|
+
* });
|
|
244
|
+
*
|
|
245
|
+
* @param {object} defaults
|
|
246
|
+
*/
|
|
247
|
+
static configureCookieDefaults(defaults = {}) {
|
|
248
|
+
_cookieDefaults = { ..._cookieDefaults, ...defaults };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Get the current cookie defaults (read-only copy).
|
|
253
|
+
* Useful for debugging or testing.
|
|
254
|
+
*
|
|
255
|
+
* @returns {object}
|
|
256
|
+
*/
|
|
257
|
+
static getCookieDefaults() {
|
|
258
|
+
return { ..._cookieDefaults };
|
|
259
|
+
}
|
|
194
260
|
}
|
|
195
261
|
|
|
196
|
-
module.exports = MillasResponse;
|
|
262
|
+
module.exports = MillasResponse;
|
|
@@ -1,46 +1,40 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const MillasResponse
|
|
3
|
+
const MillasResponse = require('./MillasResponse');
|
|
4
|
+
const { containsUnsafeHtmlPatterns } = require('./HtmlEscape');
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* ResponseDispatcher
|
|
7
8
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* Actual dispatch to the HTTP engine (setting headers, writing the body)
|
|
12
|
-
* lives in HttpAdapter.dispatch() — that is the only place HTTP-engine
|
|
13
|
-
* APIs are called.
|
|
14
|
-
*
|
|
15
|
-
* This file has zero imports of Express or any HTTP engine.
|
|
9
|
+
* Auto-wraps plain route handler return values into MillasResponse objects.
|
|
10
|
+
* In development, warns when returned HTML strings contain patterns that
|
|
11
|
+
* suggest unescaped user input. Use safeHtml`` or escapeHtml() to fix.
|
|
16
12
|
*/
|
|
17
13
|
class ResponseDispatcher {
|
|
18
14
|
|
|
19
|
-
/**
|
|
20
|
-
* Auto-wrap a plain JS return value into a MillasResponse.
|
|
21
|
-
*
|
|
22
|
-
* Called when a route handler returns something that is NOT already
|
|
23
|
-
* a MillasResponse — e.g. a plain object, string, number, or array.
|
|
24
|
-
*
|
|
25
|
-
* @param {*} value
|
|
26
|
-
* @returns {MillasResponse}
|
|
27
|
-
*/
|
|
28
15
|
static autoWrap(value) {
|
|
29
16
|
if (MillasResponse.isResponse(value)) return value;
|
|
30
|
-
|
|
31
17
|
if (value instanceof Error) throw value;
|
|
32
18
|
|
|
33
19
|
if (typeof value === 'string') {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
20
|
+
const isHtml = value.trimStart().startsWith('<');
|
|
21
|
+
|
|
22
|
+
if (isHtml && process.env.NODE_ENV !== 'production') {
|
|
23
|
+
if (containsUnsafeHtmlPatterns(value)) {
|
|
24
|
+
console.warn(
|
|
25
|
+
'[Millas] ⚠ Potentially unsafe HTML returned from route handler.\n' +
|
|
26
|
+
' The response contains unescaped patterns (<script>, onerror=, javascript:).\n' +
|
|
27
|
+
' Use safeHtml`` or escapeHtml() to escape user input:\n' +
|
|
28
|
+
' const { safeHtml } = require(\'millas/src/http/HtmlEscape\');\n' +
|
|
29
|
+
' return safeHtml`<p>${userInput}</p>`;\n'
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return isHtml ? MillasResponse.html(value) : MillasResponse.text(value);
|
|
37
35
|
}
|
|
38
36
|
|
|
39
|
-
if (
|
|
40
|
-
typeof value === 'object' ||
|
|
41
|
-
typeof value === 'number' ||
|
|
42
|
-
typeof value === 'boolean'
|
|
43
|
-
) {
|
|
37
|
+
if (typeof value === 'object' || typeof value === 'number' || typeof value === 'boolean') {
|
|
44
38
|
return MillasResponse.json(value);
|
|
45
39
|
}
|
|
46
40
|
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* SafeFilePath
|
|
8
|
+
*
|
|
9
|
+
* Prevents path traversal attacks when constructing file paths from
|
|
10
|
+
* user-provided input.
|
|
11
|
+
*
|
|
12
|
+
* A path traversal attack lets an attacker escape a storage directory by
|
|
13
|
+
* injecting sequences like '../' into a filename:
|
|
14
|
+
*
|
|
15
|
+
* /storage/uploads/ + ../../etc/passwd → /etc/passwd
|
|
16
|
+
*
|
|
17
|
+
* ── Usage ─────────────────────────────────────────────────────────────────────
|
|
18
|
+
*
|
|
19
|
+
* const { resolveStoragePath } = require('millas/src/http/SafeFilePath');
|
|
20
|
+
*
|
|
21
|
+
* // Confine a user-provided filename to a directory
|
|
22
|
+
* const safePath = resolveStoragePath(req.param('filename'), '/storage/uploads');
|
|
23
|
+
* return file(safePath);
|
|
24
|
+
*
|
|
25
|
+
* // With optional existence check
|
|
26
|
+
* const safePath = resolveStoragePath(filename, '/storage/uploads', { mustExist: true });
|
|
27
|
+
*
|
|
28
|
+
* // AI file upload — confine to storage root
|
|
29
|
+
* const safePath = resolveStoragePath(userPath, process.env.STORAGE_ROOT || '/storage');
|
|
30
|
+
* const f = await AI.files.fromPath(safePath).put();
|
|
31
|
+
*
|
|
32
|
+
* ── What it protects against ──────────────────────────────────────────────────
|
|
33
|
+
*
|
|
34
|
+
* resolveStoragePath('../../etc/passwd', '/storage/uploads')
|
|
35
|
+
* // throws PathTraversalError — '../' escapes the storage root
|
|
36
|
+
*
|
|
37
|
+
* resolveStoragePath('report%2F..%2F..%2Fetc%2Fpasswd', '/storage/uploads')
|
|
38
|
+
* // throws PathTraversalError — URL-encoded traversal is also caught
|
|
39
|
+
*
|
|
40
|
+
* resolveStoragePath('/absolute/path/outside', '/storage/uploads')
|
|
41
|
+
* // throws PathTraversalError — absolute paths that escape the root are rejected
|
|
42
|
+
*
|
|
43
|
+
* resolveStoragePath('subdir/report.pdf', '/storage/uploads')
|
|
44
|
+
* // returns '/storage/uploads/subdir/report.pdf' ✓ safe
|
|
45
|
+
*
|
|
46
|
+
* ── Configuration (config/security.js) ───────────────────────────────────────
|
|
47
|
+
*
|
|
48
|
+
* files: {
|
|
49
|
+
* storageRoot: process.env.STORAGE_ROOT || path.join(process.cwd(), 'storage'),
|
|
50
|
+
* }
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
// ── PathTraversalError ────────────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
class PathTraversalError extends Error {
|
|
56
|
+
constructor(userInput, allowedRoot) {
|
|
57
|
+
super(
|
|
58
|
+
`Path traversal attempt blocked. ` +
|
|
59
|
+
`Input "${userInput}" resolves outside the allowed directory "${allowedRoot}".`
|
|
60
|
+
);
|
|
61
|
+
this.name = 'PathTraversalError';
|
|
62
|
+
this.status = 403;
|
|
63
|
+
this.code = 'EPATH_TRAVERSAL';
|
|
64
|
+
this.input = userInput;
|
|
65
|
+
this.root = allowedRoot;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ── Core resolution ───────────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Resolve a user-provided filename/path within an allowed root directory.
|
|
73
|
+
* Throws PathTraversalError if the resolved path escapes the root.
|
|
74
|
+
*
|
|
75
|
+
* @param {string} userInput — filename or relative path from user input
|
|
76
|
+
* @param {string} allowedRoot — the directory all files must stay within
|
|
77
|
+
* @param {object} [opts]
|
|
78
|
+
* @param {boolean} [opts.mustExist=false] — throw if file doesn't exist
|
|
79
|
+
* @param {boolean} [opts.allowSubdirs=true] — allow path separators in input
|
|
80
|
+
* @returns {string} — absolute, safe file path
|
|
81
|
+
* @throws {PathTraversalError}
|
|
82
|
+
*/
|
|
83
|
+
function resolveStoragePath(userInput, allowedRoot, opts = {}) {
|
|
84
|
+
if (!userInput || typeof userInput !== 'string') {
|
|
85
|
+
throw new PathTraversalError(String(userInput), allowedRoot);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (!allowedRoot || typeof allowedRoot !== 'string') {
|
|
89
|
+
throw new Error('[Millas SafeFilePath] allowedRoot must be a non-empty string.');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ── Decode URL encoding first ───────────────────────────────────────────────
|
|
93
|
+
// Attackers may use %2F, %2E%2E etc. to bypass naive string checks
|
|
94
|
+
let decoded;
|
|
95
|
+
try {
|
|
96
|
+
decoded = decodeURIComponent(userInput);
|
|
97
|
+
} catch {
|
|
98
|
+
decoded = userInput;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ── Reject null bytes ───────────────────────────────────────────────────────
|
|
102
|
+
// Null bytes can truncate paths in some runtimes
|
|
103
|
+
if (decoded.includes('\0') || userInput.includes('\0')) {
|
|
104
|
+
throw new PathTraversalError(userInput, allowedRoot);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ── Optionally block subdirectory separators ────────────────────────────────
|
|
108
|
+
if (opts.allowSubdirs === false) {
|
|
109
|
+
if (decoded.includes('/') || decoded.includes('\\') || decoded.includes(path.sep)) {
|
|
110
|
+
throw new PathTraversalError(userInput, allowedRoot);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ── Resolve both paths to absolute, normalised forms ───────────────────────
|
|
115
|
+
const resolvedRoot = path.resolve(allowedRoot);
|
|
116
|
+
const resolvedInput = path.resolve(resolvedRoot, decoded);
|
|
117
|
+
|
|
118
|
+
// ── The core check: resolved path must start with the root ─────────────────
|
|
119
|
+
// Add path.sep to prevent '/storage/uploads-secret' matching '/storage/uploads'
|
|
120
|
+
const rootWithSep = resolvedRoot.endsWith(path.sep)
|
|
121
|
+
? resolvedRoot
|
|
122
|
+
: resolvedRoot + path.sep;
|
|
123
|
+
|
|
124
|
+
if (resolvedInput !== resolvedRoot && !resolvedInput.startsWith(rootWithSep)) {
|
|
125
|
+
throw new PathTraversalError(userInput, allowedRoot);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ── Optional existence check ────────────────────────────────────────────────
|
|
129
|
+
if (opts.mustExist && !fs.existsSync(resolvedInput)) {
|
|
130
|
+
const err = new Error(`File not found: ${path.basename(resolvedInput)}`);
|
|
131
|
+
err.status = 404;
|
|
132
|
+
err.code = 'EFILE_NOT_FOUND';
|
|
133
|
+
throw err;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return resolvedInput;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Check if a path is safely within a root directory (non-throwing version).
|
|
141
|
+
*
|
|
142
|
+
* @param {string} userInput
|
|
143
|
+
* @param {string} allowedRoot
|
|
144
|
+
* @returns {boolean}
|
|
145
|
+
*/
|
|
146
|
+
function isSafeFilePath(userInput, allowedRoot) {
|
|
147
|
+
try {
|
|
148
|
+
resolveStoragePath(userInput, allowedRoot);
|
|
149
|
+
return true;
|
|
150
|
+
} catch {
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ── SafeFilePath class ────────────────────────────────────────────────────────
|
|
156
|
+
|
|
157
|
+
class SafeFilePath {
|
|
158
|
+
/**
|
|
159
|
+
* Configure the default storage root.
|
|
160
|
+
* Called by SecurityBootstrap from config/security.js files.storageRoot.
|
|
161
|
+
*
|
|
162
|
+
* @param {string} root
|
|
163
|
+
*/
|
|
164
|
+
static setStorageRoot(root) {
|
|
165
|
+
SafeFilePath._storageRoot = root;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Get the configured storage root (or the default).
|
|
170
|
+
*/
|
|
171
|
+
static getStorageRoot() {
|
|
172
|
+
return SafeFilePath._storageRoot ||
|
|
173
|
+
process.env.STORAGE_ROOT ||
|
|
174
|
+
path.join(process.cwd(), 'storage');
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Resolve a path within the configured default storage root.
|
|
179
|
+
* Convenience wrapper for the common case.
|
|
180
|
+
*
|
|
181
|
+
* SafeFilePath.resolve('uploads/photo.jpg')
|
|
182
|
+
* // → '/var/www/myapp/storage/uploads/photo.jpg'
|
|
183
|
+
*
|
|
184
|
+
* @param {string} userInput
|
|
185
|
+
* @param {object} [opts]
|
|
186
|
+
* @returns {string}
|
|
187
|
+
*/
|
|
188
|
+
static resolve(userInput, opts = {}) {
|
|
189
|
+
return resolveStoragePath(userInput, SafeFilePath.getStorageRoot(), opts);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
SafeFilePath._storageRoot = null;
|
|
194
|
+
|
|
195
|
+
module.exports = { SafeFilePath, resolveStoragePath, isSafeFilePath, PathTraversalError };
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* SafeRedirect — INTERNAL MODULE
|
|
5
|
+
*
|
|
6
|
+
* Not part of the developer API. Developers use redirect() from helpers.js.
|
|
7
|
+
* This module is configured by SecurityBootstrap from config/app.js.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
let _allowedOrigins = [];
|
|
11
|
+
let _appOrigin = null;
|
|
12
|
+
|
|
13
|
+
function _parseOrigin(url) {
|
|
14
|
+
try { return new URL(url).origin; } catch { return null; }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function _getLower() {
|
|
18
|
+
return _allowedOrigins.map(o => _parseOrigin(o)).filter(Boolean);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function isSafeRedirect(url) {
|
|
22
|
+
if (!url || typeof url !== 'string') return false;
|
|
23
|
+
|
|
24
|
+
const trimmed = url.trim();
|
|
25
|
+
const lower = trimmed.toLowerCase().replace(/\s/g, '');
|
|
26
|
+
|
|
27
|
+
// Block dangerous schemes
|
|
28
|
+
if (lower.startsWith('javascript:') || lower.startsWith('data:') || lower.startsWith('vbscript:')) {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Relative paths (not protocol-relative) are always safe
|
|
33
|
+
if (trimmed.startsWith('/') && !trimmed.startsWith('//')) return true;
|
|
34
|
+
|
|
35
|
+
// Absolute URL — parse and check origin
|
|
36
|
+
let parsed;
|
|
37
|
+
try { parsed = new URL(trimmed); } catch { return false; }
|
|
38
|
+
if (!['http:', 'https:'].includes(parsed.protocol)) return false;
|
|
39
|
+
|
|
40
|
+
const allowed = new Set([
|
|
41
|
+
..._getLower(),
|
|
42
|
+
...(_appOrigin ? [_appOrigin] : []),
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
return allowed.has(parsed.origin);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
class SafeRedirect {
|
|
49
|
+
static configure(origins = []) {
|
|
50
|
+
_allowedOrigins = origins;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
static setAppUrl(url) {
|
|
54
|
+
_appOrigin = _parseOrigin(url);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
static isSafe(url) {
|
|
58
|
+
return isSafeRedirect(url);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
module.exports = { SafeRedirect, isSafeRedirect };
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const SecurityHeaders = require('./middleware/SecurityHeaders');
|
|
4
|
+
const CsrfMiddleware = require('./middleware/CsrfMiddleware');
|
|
5
|
+
const { RateLimiter } = require('./middleware/RateLimiter');
|
|
6
|
+
const MillasResponse = require('./MillasResponse');
|
|
7
|
+
|
|
8
|
+
class SecurityBootstrap {
|
|
9
|
+
static apply(app, config = {}) {
|
|
10
|
+
const headerConfig = config.headers !== undefined ? config.headers : {};
|
|
11
|
+
app.use(SecurityHeaders.from(headerConfig).middleware());
|
|
12
|
+
|
|
13
|
+
if (config.cookies) {
|
|
14
|
+
MillasResponse.configureCookieDefaults(config.cookies);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const globalRateLimit = RateLimiter.from(config.rateLimit?.global);
|
|
18
|
+
if (globalRateLimit) {
|
|
19
|
+
app.use(globalRateLimit.middleware());
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (config.csrf !== false) {
|
|
23
|
+
app.use(CsrfMiddleware.from(config.csrf || {}).middleware());
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
SecurityBootstrap._registerErrorHandler(app);
|
|
27
|
+
|
|
28
|
+
if (process.env.MILLAS_DEBUG_SECURITY === 'true') {
|
|
29
|
+
console.log('[Millas Security] Controls applied:');
|
|
30
|
+
console.log(' ✓ Security headers: ', headerConfig === false ? 'DISABLED' : 'enabled');
|
|
31
|
+
console.log(' ✓ Cookie defaults: ', JSON.stringify(MillasResponse.getCookieDefaults()));
|
|
32
|
+
console.log(' ✓ Global rate limit: ', globalRateLimit ? `${config.rateLimit?.global?.max || 100} req/window` : 'disabled');
|
|
33
|
+
console.log(' ✓ CSRF: ', config.csrf === false ? 'DISABLED' : 'enabled');
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
static _registerErrorHandler(app) {
|
|
38
|
+
// eslint-disable-next-line no-unused-vars
|
|
39
|
+
app.use((err, req, res, next) => {
|
|
40
|
+
if (err.code === 'EBADCSRFTOKEN') {
|
|
41
|
+
const isApi = (req.headers?.accept || '').includes('application/json') ||
|
|
42
|
+
(req.headers?.['content-type'] || '').includes('application/json');
|
|
43
|
+
res.status(403);
|
|
44
|
+
return isApi
|
|
45
|
+
? res.json({ error: 'Invalid or missing CSRF token' })
|
|
46
|
+
: res.send('Forbidden: Invalid CSRF token. Please go back and try again.');
|
|
47
|
+
}
|
|
48
|
+
if (err.code === 'EVALIDATION' || err.name === 'ValidationError') {
|
|
49
|
+
return res.status(422).json({ message: 'Validation failed', errors: err.errors || {} });
|
|
50
|
+
}
|
|
51
|
+
next(err);
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
static loadConfig(configPath) {
|
|
55
|
+
const path = require('path');
|
|
56
|
+
const fs = require('fs');
|
|
57
|
+
// Accept either a full path to app.js or a directory (falls back to config/app.js)
|
|
58
|
+
const target = configPath
|
|
59
|
+
? (configPath.endsWith('.js') ? configPath : configPath + '.js').replace(/\.js\.js$/, '.js')
|
|
60
|
+
: path.join(process.cwd(), 'config', 'app.js');
|
|
61
|
+
if (fs.existsSync(target)) {
|
|
62
|
+
try { return require(target); } catch (err) {
|
|
63
|
+
console.warn(`[Millas] Failed to load ${target}: ${err.message}. Using built-in defaults.`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return {};
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
module.exports = SecurityBootstrap;
|