millas 0.2.12-beta-2 → 0.2.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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
package/src/http/helpers.js
CHANGED
|
@@ -1,51 +1,13 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const MillasResponse
|
|
4
|
-
const HttpError
|
|
3
|
+
const MillasResponse = require('./MillasResponse');
|
|
4
|
+
const HttpError = require('../errors/HttpError');
|
|
5
|
+
const { isSafeRedirect } = require('./SafeRedirect');
|
|
5
6
|
|
|
6
|
-
/**
|
|
7
|
-
* Millas HTTP Helper Functions
|
|
8
|
-
*
|
|
9
|
-
* These are the only response-building tools developers need.
|
|
10
|
-
* Import them at the top of any route/controller file.
|
|
11
|
-
*
|
|
12
|
-
* const { jsonify, view, redirect, text, abort } = require('millas');
|
|
13
|
-
*
|
|
14
|
-
* Every helper returns a MillasResponse instance. Nothing is written
|
|
15
|
-
* to the socket until the kernel's ResponseDispatcher processes it.
|
|
16
|
-
*/
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Return a JSON response.
|
|
20
|
-
*
|
|
21
|
-
* return jsonify(users)
|
|
22
|
-
* return jsonify(user, { status: 201 })
|
|
23
|
-
* return jsonify({ error: 'Not found' }, { status: 404 })
|
|
24
|
-
* return jsonify(data).header('X-Total', String(total))
|
|
25
|
-
* return jsonify(data).cookie('token', jwt, { httpOnly: true })
|
|
26
|
-
*
|
|
27
|
-
* @param {*} data
|
|
28
|
-
* @param {object} [options]
|
|
29
|
-
* @param {number} [options.status=200]
|
|
30
|
-
* @param {object} [options.headers={}]
|
|
31
|
-
* @returns {MillasResponse}
|
|
32
|
-
*/
|
|
33
7
|
function jsonify(data, options = {}) {
|
|
34
8
|
return MillasResponse.json(data, options);
|
|
35
9
|
}
|
|
36
10
|
|
|
37
|
-
/**
|
|
38
|
-
* Return an HTML view (template) response.
|
|
39
|
-
*
|
|
40
|
-
* return view('users/index', { users })
|
|
41
|
-
* return view('emails/welcome', { user }, { status: 200 })
|
|
42
|
-
*
|
|
43
|
-
* @param {string} template — template path relative to views directory
|
|
44
|
-
* @param {object} [data={}] — data passed to the template
|
|
45
|
-
* @param {object} [options]
|
|
46
|
-
* @param {number} [options.status=200]
|
|
47
|
-
* @returns {MillasResponse}
|
|
48
|
-
*/
|
|
49
11
|
function view(template, data = {}, options = {}) {
|
|
50
12
|
return MillasResponse.view(template, data, options);
|
|
51
13
|
}
|
|
@@ -53,112 +15,65 @@ function view(template, data = {}, options = {}) {
|
|
|
53
15
|
/**
|
|
54
16
|
* Return a redirect response.
|
|
55
17
|
*
|
|
56
|
-
*
|
|
57
|
-
* return redirect('/dashboard'
|
|
58
|
-
* return redirect('
|
|
18
|
+
* Relative paths always work:
|
|
19
|
+
* return redirect('/dashboard')
|
|
20
|
+
* return redirect('/login', { status: 301 })
|
|
59
21
|
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
22
|
+
* Absolute URLs must be listed in app.allowedRedirects (config/app.js).
|
|
23
|
+
* Anything else throws a 400 — the error renderer handles it.
|
|
24
|
+
*
|
|
25
|
+
* redirect('back') uses the Referer header, validated by the same rules.
|
|
64
26
|
*/
|
|
65
27
|
function redirect(url, options = {}) {
|
|
66
|
-
|
|
28
|
+
// Resolve 'back' to the Referer header
|
|
29
|
+
const destination = url === 'back'
|
|
30
|
+
? (options.referer || (options.req ? options.req.header('referer') : null) || '/')
|
|
31
|
+
: url;
|
|
32
|
+
|
|
33
|
+
if (!isSafeRedirect(destination)) {
|
|
34
|
+
const err = new Error(`Redirect to "${destination}" is not allowed. Add it to app.allowedRedirects in config/app.js.`);
|
|
35
|
+
err.status = 400;
|
|
36
|
+
err.code = 'EREDIRECT_BLOCKED';
|
|
37
|
+
throw err;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return MillasResponse.redirect(destination, options);
|
|
67
41
|
}
|
|
68
42
|
|
|
69
|
-
/**
|
|
70
|
-
* Return a plain text response.
|
|
71
|
-
*
|
|
72
|
-
* return text('Hello, world')
|
|
73
|
-
* return text('Created', { status: 201 })
|
|
74
|
-
*
|
|
75
|
-
* @param {string} content
|
|
76
|
-
* @param {object} [options]
|
|
77
|
-
* @param {number} [options.status=200]
|
|
78
|
-
* @returns {MillasResponse}
|
|
79
|
-
*/
|
|
80
43
|
function text(content, options = {}) {
|
|
81
44
|
return MillasResponse.text(content, options);
|
|
82
45
|
}
|
|
83
46
|
|
|
84
47
|
/**
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
* return file('/storage/uploads/report.pdf', { download: true })
|
|
89
|
-
* return file('/storage/uploads/report.pdf', { download: true, name: 'report.pdf' })
|
|
90
|
-
*
|
|
91
|
-
* @param {string} filePath — absolute or relative path
|
|
92
|
-
* @param {object} [options]
|
|
93
|
-
* @param {boolean} [options.download=false] — force download (Content-Disposition: attachment)
|
|
94
|
-
* @param {string} [options.name] — filename shown to the user on download
|
|
95
|
-
* @returns {MillasResponse}
|
|
48
|
+
* Serve a file.
|
|
49
|
+
* The path is automatically validated against app.storageRoot (config/app.js).
|
|
50
|
+
* Any path that escapes the storage root throws a 403.
|
|
96
51
|
*/
|
|
97
52
|
function file(filePath, options = {}) {
|
|
53
|
+
const { resolveStoragePath, SafeFilePath } = require('./SafeFilePath');
|
|
54
|
+
const root = SafeFilePath.getStorageRoot();
|
|
55
|
+
if (root) {
|
|
56
|
+
// Throws PathTraversalError (403) if path escapes root
|
|
57
|
+
const safe = resolveStoragePath(filePath, root);
|
|
58
|
+
return MillasResponse.file(safe, options);
|
|
59
|
+
}
|
|
98
60
|
return MillasResponse.file(filePath, options);
|
|
99
61
|
}
|
|
100
62
|
|
|
101
|
-
/**
|
|
102
|
-
* Return an empty response.
|
|
103
|
-
*
|
|
104
|
-
* return empty() // 204 No Content
|
|
105
|
-
* return empty(200) // 200 with no body
|
|
106
|
-
*
|
|
107
|
-
* @param {number} [status=204]
|
|
108
|
-
* @returns {MillasResponse}
|
|
109
|
-
*/
|
|
110
63
|
function empty(status = 204) {
|
|
111
64
|
return MillasResponse.empty(status);
|
|
112
65
|
}
|
|
113
66
|
|
|
114
|
-
/**
|
|
115
|
-
* Throw an HTTP error — caught by the kernel and rendered by ErrorRenderer.
|
|
116
|
-
*
|
|
117
|
-
* abort(404)
|
|
118
|
-
* abort(403, 'You are not allowed to do that')
|
|
119
|
-
* abort(422, 'Validation failed', { email: ['Email is required'] })
|
|
120
|
-
*
|
|
121
|
-
* @param {number} status
|
|
122
|
-
* @param {string} [message]
|
|
123
|
-
* @param {object} [errors]
|
|
124
|
-
* @throws {HttpError}
|
|
125
|
-
*/
|
|
126
67
|
function abort(status, message, errors = null) {
|
|
127
68
|
throw new HttpError(status, message, errors);
|
|
128
69
|
}
|
|
129
70
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
* notFound('User not found')
|
|
134
|
-
*/
|
|
135
|
-
function notFound(message = 'Not Found') {
|
|
136
|
-
abort(404, message);
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
/**
|
|
140
|
-
* Throw a 401 Unauthorized error.
|
|
141
|
-
*/
|
|
142
|
-
function unauthorized(message = 'Unauthorized') {
|
|
143
|
-
abort(401, message);
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
/**
|
|
147
|
-
* Throw a 403 Forbidden error.
|
|
148
|
-
*/
|
|
149
|
-
function forbidden(message = 'Forbidden') {
|
|
150
|
-
abort(403, message);
|
|
151
|
-
}
|
|
71
|
+
function notFound(message = 'Not Found') { abort(404, message); }
|
|
72
|
+
function unauthorized(message = 'Unauthorized') { abort(401, message); }
|
|
73
|
+
function forbidden(message = 'Forbidden') { abort(403, message); }
|
|
152
74
|
|
|
153
75
|
module.exports = {
|
|
154
|
-
jsonify,
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
file,
|
|
159
|
-
empty,
|
|
160
|
-
abort,
|
|
161
|
-
notFound,
|
|
162
|
-
unauthorized,
|
|
163
|
-
forbidden,
|
|
164
|
-
};
|
|
76
|
+
jsonify, view, redirect,
|
|
77
|
+
text, file, empty,
|
|
78
|
+
abort, notFound, unauthorized, forbidden,
|
|
79
|
+
};
|
package/src/http/index.js
CHANGED
|
@@ -3,11 +3,20 @@
|
|
|
3
3
|
const MillasRequest = require('./MillasRequest');
|
|
4
4
|
const MillasResponse = require('./MillasResponse');
|
|
5
5
|
const ResponseDispatcher = require('./ResponseDispatcher');
|
|
6
|
+
const { RateLimiter, MemoryRateLimitStore, RedisRateLimitStore } = require('./middleware/RateLimiter');
|
|
7
|
+
const { escapeHtml, e, safeHtml, SafeString } = require('./HtmlEscape');
|
|
6
8
|
const helpers = require('./helpers');
|
|
7
9
|
|
|
8
10
|
module.exports = {
|
|
9
11
|
MillasRequest,
|
|
10
12
|
MillasResponse,
|
|
11
13
|
ResponseDispatcher,
|
|
14
|
+
RateLimiter,
|
|
15
|
+
MemoryRateLimitStore,
|
|
16
|
+
RedisRateLimitStore,
|
|
17
|
+
escapeHtml,
|
|
18
|
+
e,
|
|
19
|
+
safeHtml,
|
|
20
|
+
SafeString,
|
|
12
21
|
...helpers,
|
|
13
|
-
};
|
|
22
|
+
};
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* CsrfMiddleware
|
|
7
|
+
*
|
|
8
|
+
* Implements the Synchronizer Token Pattern to prevent Cross-Site Request Forgery.
|
|
9
|
+
* Enabled by default for all state-changing requests (POST, PUT, PATCH, DELETE).
|
|
10
|
+
* API routes and explicitly exempted paths opt out.
|
|
11
|
+
*
|
|
12
|
+
* ── How it works ─────────────────────────────────────────────────────────────
|
|
13
|
+
*
|
|
14
|
+
* 1. On any GET request, a signed CSRF token is generated and stored in a
|
|
15
|
+
* cookie (_csrf) + made available via req.csrfToken().
|
|
16
|
+
* 2. On state-changing requests (POST/PUT/PATCH/DELETE), the middleware reads
|
|
17
|
+
* the submitted token from the request body, query string, or header and
|
|
18
|
+
* verifies it matches the signed value from the cookie.
|
|
19
|
+
* 3. If verification fails, a 403 is thrown automatically.
|
|
20
|
+
*
|
|
21
|
+
* ── Usage in templates ────────────────────────────────────────────────────────
|
|
22
|
+
*
|
|
23
|
+
* <!-- In every HTML form — required -->
|
|
24
|
+
* <form method="POST" action="/register">
|
|
25
|
+
* <input type="hidden" name="_csrf" value="<%= req.csrfToken() %>">
|
|
26
|
+
* ...
|
|
27
|
+
* </form>
|
|
28
|
+
*
|
|
29
|
+
* ── Usage in fetch / AJAX ─────────────────────────────────────────────────────
|
|
30
|
+
*
|
|
31
|
+
* // Read the token from the meta tag or a dedicated endpoint
|
|
32
|
+
* const token = document.querySelector('meta[name="csrf-token"]').content;
|
|
33
|
+
*
|
|
34
|
+
* fetch('/api/profile', {
|
|
35
|
+
* method: 'POST',
|
|
36
|
+
* headers: { 'X-CSRF-Token': token },
|
|
37
|
+
* body: JSON.stringify(data),
|
|
38
|
+
* });
|
|
39
|
+
*
|
|
40
|
+
* ── Exempting routes ──────────────────────────────────────────────────────────
|
|
41
|
+
*
|
|
42
|
+
* // config/security.js — exempt entire path prefixes (e.g. REST API routes)
|
|
43
|
+
* csrf: {
|
|
44
|
+
* exclude: ['/api/', '/webhooks/'],
|
|
45
|
+
* }
|
|
46
|
+
*
|
|
47
|
+
* // Or use the per-route decorator (when routing module supports it):
|
|
48
|
+
* Route.post('/webhook/stripe', { csrf: false }, handler);
|
|
49
|
+
*
|
|
50
|
+
* ── Configuration (config/security.js) ───────────────────────────────────────
|
|
51
|
+
*
|
|
52
|
+
* csrf: {
|
|
53
|
+
* cookieName: '_csrf', // name of the cookie holding the token
|
|
54
|
+
* fieldName: '_csrf', // HTML form field / body key
|
|
55
|
+
* headerName: 'x-csrf-token', // AJAX header name
|
|
56
|
+
* exclude: ['/api/'], // path prefixes that skip CSRF checks
|
|
57
|
+
* tokenLength: 32, // bytes of random entropy in the token
|
|
58
|
+
* }
|
|
59
|
+
*
|
|
60
|
+
* ── Security notes ────────────────────────────────────────────────────────────
|
|
61
|
+
*
|
|
62
|
+
* • Tokens are HMAC-signed (SHA-256) using a secret derived from APP_SECRET.
|
|
63
|
+
* A raw random token without signing would still work against CSRF, but
|
|
64
|
+
* signing prevents an attacker from crafting a valid token without the secret.
|
|
65
|
+
* • The cookie is HttpOnly: false by design — the client-side JS needs to read
|
|
66
|
+
* it for AJAX requests. The CSRF cookie alone has no value to an attacker
|
|
67
|
+
* because they also need to submit the matching signed token in the request.
|
|
68
|
+
* • SameSite: Strict on the CSRF cookie provides defence-in-depth.
|
|
69
|
+
* • Double-submit pattern is used: cookie value is verified against the submitted
|
|
70
|
+
* token, so subdomain compromise is mitigated by the HMAC signing.
|
|
71
|
+
*/
|
|
72
|
+
|
|
73
|
+
// ── Constants ─────────────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
|
76
|
+
const DEFAULT_CONFIG = {
|
|
77
|
+
cookieName: '_csrf',
|
|
78
|
+
fieldName: '_csrf',
|
|
79
|
+
headerName: 'x-csrf-token',
|
|
80
|
+
exclude: [],
|
|
81
|
+
tokenLength: 32,
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// ── Token utilities ────────────────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Derive an HMAC signing secret from APP_SECRET.
|
|
88
|
+
* Falls back to a fixed warning value if APP_SECRET is not set —
|
|
89
|
+
* this will still work but logs a warning in development.
|
|
90
|
+
*/
|
|
91
|
+
function getSecret() {
|
|
92
|
+
const secret = process.env.APP_SECRET;
|
|
93
|
+
if (!secret && process.env.NODE_ENV !== 'test') {
|
|
94
|
+
console.warn(
|
|
95
|
+
'[Millas CSRF] WARNING: APP_SECRET environment variable is not set. ' +
|
|
96
|
+
'CSRF tokens are weakly signed. Set APP_SECRET in your .env file.'
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
return crypto.createHash('sha256')
|
|
100
|
+
.update(secret || 'millas-csrf-insecure-default')
|
|
101
|
+
.digest();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Generate a new signed CSRF token.
|
|
106
|
+
* Format: <random_hex>.<hmac_hex>
|
|
107
|
+
*
|
|
108
|
+
* @param {number} length — bytes of random entropy
|
|
109
|
+
* @returns {string}
|
|
110
|
+
*/
|
|
111
|
+
function generateToken(length = 32) {
|
|
112
|
+
const random = crypto.randomBytes(length).toString('hex');
|
|
113
|
+
const hmac = crypto.createHmac('sha256', getSecret())
|
|
114
|
+
.update(random)
|
|
115
|
+
.digest('hex');
|
|
116
|
+
return `${random}.${hmac}`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Verify a submitted token against its HMAC signature.
|
|
121
|
+
* Uses timingSafeEqual to prevent timing attacks.
|
|
122
|
+
*
|
|
123
|
+
* @param {string} token
|
|
124
|
+
* @returns {boolean}
|
|
125
|
+
*/
|
|
126
|
+
function verifyToken(token) {
|
|
127
|
+
if (typeof token !== 'string') return false;
|
|
128
|
+
|
|
129
|
+
const dotIndex = token.lastIndexOf('.');
|
|
130
|
+
if (dotIndex === -1) return false;
|
|
131
|
+
|
|
132
|
+
const random = token.slice(0, dotIndex);
|
|
133
|
+
const provided = token.slice(dotIndex + 1);
|
|
134
|
+
|
|
135
|
+
if (!random || !provided) return false;
|
|
136
|
+
|
|
137
|
+
const expected = crypto.createHmac('sha256', getSecret())
|
|
138
|
+
.update(random)
|
|
139
|
+
.digest('hex');
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
const a = Buffer.from(provided, 'hex');
|
|
143
|
+
const b = Buffer.from(expected, 'hex');
|
|
144
|
+
if (a.length !== b.length) return false;
|
|
145
|
+
return crypto.timingSafeEqual(a, b);
|
|
146
|
+
} catch {
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ── CsrfMiddleware class ───────────────────────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
class CsrfMiddleware {
|
|
154
|
+
/**
|
|
155
|
+
* @param {object} config — merged with DEFAULT_CONFIG
|
|
156
|
+
*/
|
|
157
|
+
constructor(config = {}) {
|
|
158
|
+
this._cfg = { ...DEFAULT_CONFIG, ...config };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ── Express middleware ────────────────────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Returns an Express-compatible middleware function.
|
|
165
|
+
*
|
|
166
|
+
* Attaches req.csrfToken() to every request for use in templates.
|
|
167
|
+
* Validates the token on state-changing methods.
|
|
168
|
+
*/
|
|
169
|
+
middleware() {
|
|
170
|
+
const cfg = this._cfg;
|
|
171
|
+
|
|
172
|
+
return (req, res, next) => {
|
|
173
|
+
// ── Check if this path is excluded ────────────────────────────────────
|
|
174
|
+
if (this._isExcluded(req.path || req.url, cfg.exclude)) {
|
|
175
|
+
req.csrfToken = () => '';
|
|
176
|
+
return next();
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ── Always attach csrfToken() helper to req ───────────────────────────
|
|
180
|
+
// Lazily generate + cache the token for this request cycle
|
|
181
|
+
let _token = null;
|
|
182
|
+
req.csrfToken = () => {
|
|
183
|
+
if (!_token) {
|
|
184
|
+
// Re-use existing cookie token if valid, otherwise generate new one
|
|
185
|
+
const existing = req.cookies?.[cfg.cookieName];
|
|
186
|
+
_token = (existing && verifyToken(existing)) ? existing : generateToken(cfg.tokenLength);
|
|
187
|
+
|
|
188
|
+
// Set / refresh the CSRF cookie
|
|
189
|
+
// httpOnly: false — client JS must read this for AJAX
|
|
190
|
+
// sameSite: Strict — defence-in-depth
|
|
191
|
+
res.cookie(cfg.cookieName, _token, {
|
|
192
|
+
httpOnly: false,
|
|
193
|
+
sameSite: 'Strict',
|
|
194
|
+
secure: process.env.NODE_ENV === 'production',
|
|
195
|
+
path: '/',
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
return _token;
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
// ── Safe methods — no validation needed, just ensure cookie is set ────
|
|
202
|
+
if (SAFE_METHODS.has(req.method)) {
|
|
203
|
+
// Touch the token so the cookie is always present after a GET
|
|
204
|
+
req.csrfToken();
|
|
205
|
+
return next();
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ── State-changing methods — validate the submitted token ─────────────
|
|
209
|
+
const submitted =
|
|
210
|
+
req.body?.[cfg.fieldName] || // form field
|
|
211
|
+
req.query?.[cfg.fieldName] || // query string (not recommended but supported)
|
|
212
|
+
req.headers?.[cfg.headerName] || // AJAX header (X-CSRF-Token)
|
|
213
|
+
req.headers?.['x-xsrf-token']; // Angular-style header alias
|
|
214
|
+
|
|
215
|
+
if (!submitted || !verifyToken(submitted)) {
|
|
216
|
+
const err = new Error('Invalid or missing CSRF token');
|
|
217
|
+
err.status = 403;
|
|
218
|
+
err.code = 'EBADCSRFTOKEN';
|
|
219
|
+
return next(err);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ── Rotate the token after a successful state-changing request ─────────
|
|
223
|
+
// Regenerate so each token is single-use (defence-in-depth)
|
|
224
|
+
_token = null; // force new token on next csrfToken() call
|
|
225
|
+
|
|
226
|
+
next();
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ── Internal helpers ──────────────────────────────────────────────────────
|
|
231
|
+
|
|
232
|
+
_isExcluded(path, excludeList) {
|
|
233
|
+
if (!excludeList || excludeList.length === 0) return false;
|
|
234
|
+
return excludeList.some(prefix => path.startsWith(prefix));
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ── Static factory ────────────────────────────────────────────────────────
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Create a CsrfMiddleware from a config section.
|
|
241
|
+
*
|
|
242
|
+
* CsrfMiddleware.from(config.security?.csrf)
|
|
243
|
+
*
|
|
244
|
+
* @param {object|false|undefined} config
|
|
245
|
+
* @returns {CsrfMiddleware}
|
|
246
|
+
*/
|
|
247
|
+
static from(config) {
|
|
248
|
+
return new CsrfMiddleware(config || {});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Expose token utilities for testing and custom integrations.
|
|
253
|
+
*/
|
|
254
|
+
static generateToken(length) { return generateToken(length); }
|
|
255
|
+
static verifyToken(token) { return verifyToken(token); }
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
module.exports = CsrfMiddleware;
|