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
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* RateLimiter
|
|
5
|
+
*
|
|
6
|
+
* Per-IP and per-user rate limiting with an in-memory store by default
|
|
7
|
+
* and a pluggable store interface for Redis in production.
|
|
8
|
+
*
|
|
9
|
+
* ── Quick usage ───────────────────────────────────────────────────────────────
|
|
10
|
+
*
|
|
11
|
+
* const { RateLimiter } = require('millas/src/http/middleware/RateLimiter');
|
|
12
|
+
*
|
|
13
|
+
* // Global: 100 requests per 15 minutes per IP (applied in SecurityBootstrap)
|
|
14
|
+
* app.use(RateLimiter.perIp({ max: 100, windowMs: 15 * 60 * 1000 }).middleware());
|
|
15
|
+
*
|
|
16
|
+
* // Route-level: 5 login attempts per 10 minutes per IP
|
|
17
|
+
* Route.post('/login', [
|
|
18
|
+
* RateLimiter.perIp({ max: 5, windowMs: 10 * 60 * 1000, message: 'Too many login attempts' }).middleware(),
|
|
19
|
+
* ], loginHandler);
|
|
20
|
+
*
|
|
21
|
+
* // Authenticated routes: per-user limiting
|
|
22
|
+
* Route.post('/ai/chat', [
|
|
23
|
+
* RateLimiter.perUser({ max: 20, windowMs: 60 * 1000 }).middleware(),
|
|
24
|
+
* ], chatHandler);
|
|
25
|
+
*
|
|
26
|
+
* // Combined: per-IP first, then per-user
|
|
27
|
+
* Route.post('/api/messages', [
|
|
28
|
+
* RateLimiter.perIp({ max: 60, windowMs: 60 * 1000 }).middleware(),
|
|
29
|
+
* RateLimiter.perUser({ max: 30, windowMs: 60 * 1000 }).middleware(),
|
|
30
|
+
* ], handler);
|
|
31
|
+
*
|
|
32
|
+
* ── Redis store (production) ──────────────────────────────────────────────────
|
|
33
|
+
*
|
|
34
|
+
* const { RedisRateLimitStore } = require('millas/src/http/middleware/RateLimiter');
|
|
35
|
+
* const redis = require('ioredis');
|
|
36
|
+
*
|
|
37
|
+
* const store = new RedisRateLimitStore(new redis(process.env.REDIS_URL));
|
|
38
|
+
*
|
|
39
|
+
* RateLimiter.perIp({ max: 100, windowMs: 60000, store }).middleware();
|
|
40
|
+
*
|
|
41
|
+
* ── Configuration (config/security.js) ───────────────────────────────────────
|
|
42
|
+
*
|
|
43
|
+
* rateLimit: {
|
|
44
|
+
* global: {
|
|
45
|
+
* enabled: true,
|
|
46
|
+
* max: 100,
|
|
47
|
+
* windowMs: 15 * 60 * 1000, // 15 minutes
|
|
48
|
+
* },
|
|
49
|
+
* }
|
|
50
|
+
*
|
|
51
|
+
* ── Response headers ──────────────────────────────────────────────────────────
|
|
52
|
+
*
|
|
53
|
+
* Every rate-limited response includes:
|
|
54
|
+
* X-RateLimit-Limit: 100 — max requests in window
|
|
55
|
+
* X-RateLimit-Remaining: 42 — requests left in current window
|
|
56
|
+
* X-RateLimit-Reset: 1711234567 — UNIX timestamp when window resets
|
|
57
|
+
*
|
|
58
|
+
* On 429:
|
|
59
|
+
* Retry-After: 300 — seconds until the window resets
|
|
60
|
+
*/
|
|
61
|
+
|
|
62
|
+
// ── In-memory store ────────────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* MemoryRateLimitStore
|
|
66
|
+
*
|
|
67
|
+
* Simple in-memory store using a Map.
|
|
68
|
+
* Fine for single-process deployments and development.
|
|
69
|
+
* For multi-process or multi-server deployments, use RedisRateLimitStore.
|
|
70
|
+
*
|
|
71
|
+
* Entries expire automatically — a cleanup sweep runs every 5 minutes
|
|
72
|
+
* to prevent unbounded memory growth.
|
|
73
|
+
*/
|
|
74
|
+
class MemoryRateLimitStore {
|
|
75
|
+
constructor() {
|
|
76
|
+
this._store = new Map();
|
|
77
|
+
this._cleanup = setInterval(() => this._sweep(), 5 * 60 * 1000);
|
|
78
|
+
// Don't hold the process open just for cleanup
|
|
79
|
+
if (this._cleanup.unref) this._cleanup.unref();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Increment hit count for a key within a window.
|
|
84
|
+
*
|
|
85
|
+
* @param {string} key
|
|
86
|
+
* @param {number} windowMs
|
|
87
|
+
* @returns {{ count: number, resetAt: number }}
|
|
88
|
+
*/
|
|
89
|
+
async increment(key, windowMs) {
|
|
90
|
+
const now = Date.now();
|
|
91
|
+
const entry = this._store.get(key);
|
|
92
|
+
const resetAt = entry && entry.resetAt > now ? entry.resetAt : now + windowMs;
|
|
93
|
+
const count = entry && entry.resetAt > now ? entry.count + 1 : 1;
|
|
94
|
+
|
|
95
|
+
this._store.set(key, { count, resetAt });
|
|
96
|
+
return { count, resetAt };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Reset the counter for a key (e.g. after successful login).
|
|
101
|
+
*
|
|
102
|
+
* @param {string} key
|
|
103
|
+
*/
|
|
104
|
+
async reset(key) {
|
|
105
|
+
this._store.delete(key);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Stop the background cleanup interval.
|
|
110
|
+
* Call this in tests or on graceful shutdown to allow the process to exit.
|
|
111
|
+
*/
|
|
112
|
+
destroy() {
|
|
113
|
+
clearInterval(this._cleanup);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Remove expired entries */
|
|
117
|
+
_sweep() {
|
|
118
|
+
const now = Date.now();
|
|
119
|
+
for (const [key, entry] of this._store) {
|
|
120
|
+
if (entry.resetAt <= now) this._store.delete(key);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ── Redis store ────────────────────────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* RedisRateLimitStore
|
|
129
|
+
*
|
|
130
|
+
* Production store backed by Redis. Requires an ioredis (or node-redis) client.
|
|
131
|
+
* Uses atomic INCR + PEXPIRE so it works correctly across multiple processes.
|
|
132
|
+
*
|
|
133
|
+
* @example
|
|
134
|
+
* const redis = new Redis(process.env.REDIS_URL);
|
|
135
|
+
* const store = new RedisRateLimitStore(redis);
|
|
136
|
+
* RateLimiter.perIp({ max: 100, windowMs: 60000, store });
|
|
137
|
+
*/
|
|
138
|
+
class RedisRateLimitStore {
|
|
139
|
+
/**
|
|
140
|
+
* @param {object} redisClient — ioredis or node-redis client instance
|
|
141
|
+
* @param {string} [prefix] — key prefix (default: 'rl:')
|
|
142
|
+
*/
|
|
143
|
+
constructor(redisClient, prefix = 'rl:') {
|
|
144
|
+
this._redis = redisClient;
|
|
145
|
+
this._prefix = prefix;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async increment(key, windowMs) {
|
|
149
|
+
const redisKey = `${this._prefix}${key}`;
|
|
150
|
+
|
|
151
|
+
// Atomic pipeline: INCR then set expiry only on first hit
|
|
152
|
+
const [[, count], [, ttlMs]] = await this._redis
|
|
153
|
+
.pipeline()
|
|
154
|
+
.incr(redisKey)
|
|
155
|
+
.pttl(redisKey)
|
|
156
|
+
.exec();
|
|
157
|
+
|
|
158
|
+
// If this is the first hit (or key had no TTL), set the window expiry
|
|
159
|
+
if (count === 1 || ttlMs < 0) {
|
|
160
|
+
await this._redis.pexpire(redisKey, windowMs);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const resetAt = Date.now() + (ttlMs > 0 ? ttlMs : windowMs);
|
|
164
|
+
return { count, resetAt };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async reset(key) {
|
|
168
|
+
await this._redis.del(`${this._prefix}${key}`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ── RateLimiter class ──────────────────────────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
const DEFAULT_OPTIONS = {
|
|
175
|
+
max: 100,
|
|
176
|
+
windowMs: 15 * 60 * 1000, // 15 minutes
|
|
177
|
+
message: 'Too many requests, please try again later.',
|
|
178
|
+
statusCode: 429,
|
|
179
|
+
keyBy: 'ip', // 'ip' | 'user' | function(req) => string
|
|
180
|
+
store: null, // defaults to MemoryRateLimitStore
|
|
181
|
+
skip: null, // optional function(req) => bool — return true to skip
|
|
182
|
+
onLimitReached: null, // optional function(req, res, options) — called on 429
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
class RateLimiter {
|
|
186
|
+
/**
|
|
187
|
+
* @param {object} options
|
|
188
|
+
*/
|
|
189
|
+
constructor(options = {}) {
|
|
190
|
+
this._opts = { ...DEFAULT_OPTIONS, ...options };
|
|
191
|
+
this._store = options.store || new MemoryRateLimitStore();
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ── Express middleware ────────────────────────────────────────────────────
|
|
195
|
+
|
|
196
|
+
middleware() {
|
|
197
|
+
const opts = this._opts;
|
|
198
|
+
const store = this._store;
|
|
199
|
+
|
|
200
|
+
return async (req, res, next) => {
|
|
201
|
+
try {
|
|
202
|
+
// ── Skip check ─────────────────────────────────────────────────────
|
|
203
|
+
if (opts.skip && opts.skip(req)) return next();
|
|
204
|
+
|
|
205
|
+
// ── Resolve key ────────────────────────────────────────────────────
|
|
206
|
+
const key = this._resolveKey(req, opts.keyBy);
|
|
207
|
+
if (!key) return next(); // can't rate-limit without a key (e.g. no user yet)
|
|
208
|
+
|
|
209
|
+
// ── Increment counter ──────────────────────────────────────────────
|
|
210
|
+
const { count, resetAt } = await store.increment(key, opts.windowMs);
|
|
211
|
+
const remaining = Math.max(0, opts.max - count);
|
|
212
|
+
const resetSec = Math.ceil(resetAt / 1000);
|
|
213
|
+
|
|
214
|
+
// ── Set rate-limit headers (always, even on 429) ───────────────────
|
|
215
|
+
res.setHeader('X-RateLimit-Limit', opts.max);
|
|
216
|
+
res.setHeader('X-RateLimit-Remaining', remaining);
|
|
217
|
+
res.setHeader('X-RateLimit-Reset', resetSec);
|
|
218
|
+
|
|
219
|
+
// ── Attach store reset helper to req for auth flows ────────────────
|
|
220
|
+
// e.g. after successful login: await req.resetRateLimit()
|
|
221
|
+
req.resetRateLimit = () => store.reset(key);
|
|
222
|
+
|
|
223
|
+
// ── Enforce limit ──────────────────────────────────────────────────
|
|
224
|
+
if (count > opts.max) {
|
|
225
|
+
const retryAfter = Math.ceil((resetAt - Date.now()) / 1000);
|
|
226
|
+
res.setHeader('Retry-After', retryAfter);
|
|
227
|
+
|
|
228
|
+
if (opts.onLimitReached) {
|
|
229
|
+
opts.onLimitReached(req, res, opts);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Return JSON for API requests, plain text for others
|
|
233
|
+
const isApi = (req.headers?.accept || '').includes('application/json') ||
|
|
234
|
+
(req.headers?.['content-type'] || '').includes('application/json');
|
|
235
|
+
|
|
236
|
+
res.status(opts.statusCode);
|
|
237
|
+
if (isApi) {
|
|
238
|
+
res.setHeader('Content-Type', 'application/json');
|
|
239
|
+
return res.end(JSON.stringify({ error: opts.message, retryAfter }));
|
|
240
|
+
}
|
|
241
|
+
return res.end(opts.message);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
next();
|
|
245
|
+
} catch (err) {
|
|
246
|
+
// Store errors must never block requests — fail open
|
|
247
|
+
console.error('[Millas RateLimit] Store error:', err.message);
|
|
248
|
+
next();
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ── Internal helpers ──────────────────────────────────────────────────────
|
|
254
|
+
|
|
255
|
+
_resolveKey(req, keyBy) {
|
|
256
|
+
if (typeof keyBy === 'function') return keyBy(req);
|
|
257
|
+
|
|
258
|
+
if (keyBy === 'user') {
|
|
259
|
+
// req.user is set by AuthMiddleware — if not authenticated, fall back to IP
|
|
260
|
+
const userId = req.user?.id || req.user?._id;
|
|
261
|
+
return userId ? `user:${userId}` : this._resolveKey(req, 'ip');
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (keyBy === 'ip') {
|
|
265
|
+
const ip = req.ip ||
|
|
266
|
+
req.headers?.['x-forwarded-for']?.split(',')[0]?.trim() ||
|
|
267
|
+
req.connection?.remoteAddress;
|
|
268
|
+
return ip ? `ip:${ip}` : null;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return null;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ── Static factories (fluent API) ─────────────────────────────────────────
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Limit by IP address.
|
|
278
|
+
*
|
|
279
|
+
* RateLimiter.perIp({ max: 5, windowMs: 10 * 60 * 1000 }).middleware()
|
|
280
|
+
*/
|
|
281
|
+
static perIp(options = {}) {
|
|
282
|
+
return new RateLimiter({ ...options, keyBy: 'ip' });
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Limit by authenticated user (falls back to IP if not authenticated).
|
|
287
|
+
*
|
|
288
|
+
* RateLimiter.perUser({ max: 20, windowMs: 60 * 1000 }).middleware()
|
|
289
|
+
*/
|
|
290
|
+
static perUser(options = {}) {
|
|
291
|
+
return new RateLimiter({ ...options, keyBy: 'user' });
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Limit by a custom key resolver.
|
|
296
|
+
*
|
|
297
|
+
* RateLimiter.by(req => req.headers['x-api-key'], { max: 1000 }).middleware()
|
|
298
|
+
*/
|
|
299
|
+
static by(keyFn, options = {}) {
|
|
300
|
+
return new RateLimiter({ ...options, keyBy: keyFn });
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Create from config section.
|
|
305
|
+
*
|
|
306
|
+
* RateLimiter.from(config.security?.rateLimit?.global)
|
|
307
|
+
*/
|
|
308
|
+
static from(options) {
|
|
309
|
+
if (options === false || options?.enabled === false) return null;
|
|
310
|
+
return RateLimiter.perIp(options || {});
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
module.exports = { RateLimiter, MemoryRateLimitStore, RedisRateLimitStore };
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* SecurityHeaders middleware
|
|
5
|
+
*
|
|
6
|
+
* Sets secure HTTP response headers on every outgoing response.
|
|
7
|
+
* Enabled by default in the Millas HTTP kernel — opt-out, not opt-in.
|
|
8
|
+
*
|
|
9
|
+
* Covers:
|
|
10
|
+
* • Content-Security-Policy — XSS mitigation
|
|
11
|
+
* • Strict-Transport-Security — HTTPS enforcement
|
|
12
|
+
* • X-Frame-Options — clickjacking prevention
|
|
13
|
+
* • X-Content-Type-Options — MIME sniffing prevention
|
|
14
|
+
* • Referrer-Policy — referrer leakage prevention
|
|
15
|
+
* • Permissions-Policy — browser feature scoping
|
|
16
|
+
* • X-Powered-By removal — fingerprinting reduction
|
|
17
|
+
*
|
|
18
|
+
* ── Usage (automatic — loaded by HttpKernel) ────────────────────────────────
|
|
19
|
+
*
|
|
20
|
+
* // Loaded automatically. No developer action required.
|
|
21
|
+
*
|
|
22
|
+
* ── Customising CSP (config/security.js) ────────────────────────────────────
|
|
23
|
+
*
|
|
24
|
+
* module.exports = {
|
|
25
|
+
* headers: {
|
|
26
|
+
* contentSecurityPolicy: {
|
|
27
|
+
* directives: {
|
|
28
|
+
* defaultSrc: ["'self'"],
|
|
29
|
+
* scriptSrc: ["'self'", 'cdn.example.com'],
|
|
30
|
+
* imgSrc: ["'self'", 'data:', 'https:'],
|
|
31
|
+
* },
|
|
32
|
+
* },
|
|
33
|
+
* // Disable a specific header:
|
|
34
|
+
* xFrameOptions: false,
|
|
35
|
+
* },
|
|
36
|
+
* };
|
|
37
|
+
*
|
|
38
|
+
* ── Disabling entirely (not recommended) ───────────────────────────────────
|
|
39
|
+
*
|
|
40
|
+
* // config/security.js
|
|
41
|
+
* module.exports = { headers: false };
|
|
42
|
+
*
|
|
43
|
+
* ── Nonces (for inline scripts) ────────────────────────────────────────────
|
|
44
|
+
*
|
|
45
|
+
* // The middleware attaches a per-request nonce to req.cspNonce
|
|
46
|
+
* // Use it in templates: <script nonce="<%= req.cspNonce %>">
|
|
47
|
+
* //
|
|
48
|
+
* // Enable via config:
|
|
49
|
+
* contentSecurityPolicy: {
|
|
50
|
+
* useNonce: true,
|
|
51
|
+
* directives: {
|
|
52
|
+
* scriptSrc: ["'self'"], // nonce is appended automatically
|
|
53
|
+
* },
|
|
54
|
+
* },
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
const crypto = require('crypto');
|
|
58
|
+
|
|
59
|
+
// ── Defaults ──────────────────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
const DEFAULTS = {
|
|
62
|
+
contentSecurityPolicy: {
|
|
63
|
+
useNonce: false,
|
|
64
|
+
directives: {
|
|
65
|
+
defaultSrc: ["'self'"],
|
|
66
|
+
scriptSrc: ["'self'"],
|
|
67
|
+
styleSrc: ["'self'", "'unsafe-inline'"],
|
|
68
|
+
imgSrc: ["'self'", 'data:'],
|
|
69
|
+
fontSrc: ["'self'"],
|
|
70
|
+
connectSrc: ["'self'"],
|
|
71
|
+
objectSrc: ["'none'"],
|
|
72
|
+
frameSrc: ["'none'"],
|
|
73
|
+
baseUri: ["'self'"],
|
|
74
|
+
formAction: ["'self'"],
|
|
75
|
+
frameAncestors:["'none'"],
|
|
76
|
+
upgradeInsecureRequests: [],
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
// HSTS: 1 year, include subdomains, allow preload
|
|
81
|
+
// Only sent over HTTPS — the middleware checks req.secure
|
|
82
|
+
strictTransportSecurity: {
|
|
83
|
+
maxAge: 31536000,
|
|
84
|
+
includeSubDomains: true,
|
|
85
|
+
preload: true,
|
|
86
|
+
},
|
|
87
|
+
|
|
88
|
+
// Deny framing entirely — use 'SAMEORIGIN' if you need iframes on your own domain
|
|
89
|
+
xFrameOptions: 'DENY',
|
|
90
|
+
|
|
91
|
+
// Prevent MIME-type sniffing
|
|
92
|
+
xContentTypeOptions: true,
|
|
93
|
+
|
|
94
|
+
// Only send the origin (no path) as referrer; never send cross-origin referrer
|
|
95
|
+
referrerPolicy: 'strict-origin-when-cross-origin',
|
|
96
|
+
|
|
97
|
+
// Disable access to sensitive browser features by default
|
|
98
|
+
permissionsPolicy: {
|
|
99
|
+
camera: '()',
|
|
100
|
+
microphone: '()',
|
|
101
|
+
geolocation: '()',
|
|
102
|
+
payment: '()',
|
|
103
|
+
usb: '()',
|
|
104
|
+
magnetometer: '()',
|
|
105
|
+
gyroscope: '()',
|
|
106
|
+
accelerometer: '()',
|
|
107
|
+
},
|
|
108
|
+
|
|
109
|
+
// Remove X-Powered-By: Express
|
|
110
|
+
removePoweredBy: true,
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
// ── CSP builder ───────────────────────────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Build a Content-Security-Policy header value from a directives object.
|
|
117
|
+
*
|
|
118
|
+
* @param {object} directives
|
|
119
|
+
* @param {string|null} nonce
|
|
120
|
+
* @returns {string}
|
|
121
|
+
*/
|
|
122
|
+
function buildCsp(directives, nonce = null) {
|
|
123
|
+
return Object.entries(directives)
|
|
124
|
+
.map(([key, value]) => {
|
|
125
|
+
// camelCase → kebab-case
|
|
126
|
+
const directive = key.replace(/([A-Z])/g, '-$1').toLowerCase();
|
|
127
|
+
|
|
128
|
+
if (!Array.isArray(value) || value.length === 0) {
|
|
129
|
+
// Bare directives (e.g. upgrade-insecure-requests)
|
|
130
|
+
if (Array.isArray(value) && value.length === 0) return directive;
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
let sources = [...value];
|
|
135
|
+
|
|
136
|
+
// Inject nonce into scriptSrc and styleSrc if enabled
|
|
137
|
+
if (nonce && (key === 'scriptSrc' || key === 'styleSrc')) {
|
|
138
|
+
sources = sources.filter(s => !s.startsWith("'nonce-"));
|
|
139
|
+
sources.push(`'nonce-${nonce}'`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return `${directive} ${sources.join(' ')}`;
|
|
143
|
+
})
|
|
144
|
+
.filter(Boolean)
|
|
145
|
+
.join('; ');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ── Permissions-Policy builder ────────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
function buildPermissionsPolicy(features) {
|
|
151
|
+
return Object.entries(features)
|
|
152
|
+
.map(([feature, allowlist]) => `${feature}=${allowlist}`)
|
|
153
|
+
.join(', ');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ── Merge helpers ─────────────────────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
function mergeDeep(target, source) {
|
|
159
|
+
const result = { ...target };
|
|
160
|
+
for (const key of Object.keys(source)) {
|
|
161
|
+
if (
|
|
162
|
+
source[key] !== null &&
|
|
163
|
+
typeof source[key] === 'object' &&
|
|
164
|
+
!Array.isArray(source[key]) &&
|
|
165
|
+
typeof target[key] === 'object' &&
|
|
166
|
+
!Array.isArray(target[key])
|
|
167
|
+
) {
|
|
168
|
+
result[key] = mergeDeep(target[key], source[key]);
|
|
169
|
+
} else {
|
|
170
|
+
result[key] = source[key];
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return result;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ── SecurityHeaders class ─────────────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
class SecurityHeaders {
|
|
179
|
+
/**
|
|
180
|
+
* @param {object|false} config — merged with DEFAULTS; pass false to disable
|
|
181
|
+
*/
|
|
182
|
+
constructor(config = {}) {
|
|
183
|
+
if (config === false) {
|
|
184
|
+
this._disabled = true;
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
this._disabled = false;
|
|
189
|
+
this._config = mergeDeep(DEFAULTS, config);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ── Express middleware ────────────────────────────────────────────────────
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Returns an Express-compatible middleware function.
|
|
196
|
+
*
|
|
197
|
+
* app.use(new SecurityHeaders(config).middleware());
|
|
198
|
+
*
|
|
199
|
+
* The Millas HttpKernel calls this automatically — developers
|
|
200
|
+
* do not need to call it directly unless customising bootstrap.
|
|
201
|
+
*/
|
|
202
|
+
middleware() {
|
|
203
|
+
if (this._disabled) {
|
|
204
|
+
return (_req, _res, next) => next();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const cfg = this._config;
|
|
208
|
+
|
|
209
|
+
return (req, res, next) => {
|
|
210
|
+
// ── Remove X-Powered-By ──────────────────────────────────────────────
|
|
211
|
+
if (cfg.removePoweredBy) {
|
|
212
|
+
res.removeHeader('X-Powered-By');
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ── Content-Security-Policy ──────────────────────────────────────────
|
|
216
|
+
if (cfg.contentSecurityPolicy !== false) {
|
|
217
|
+
const cspCfg = cfg.contentSecurityPolicy;
|
|
218
|
+
let nonce = null;
|
|
219
|
+
|
|
220
|
+
if (cspCfg.useNonce) {
|
|
221
|
+
nonce = crypto.randomBytes(16).toString('base64');
|
|
222
|
+
// Attach to req so templates can use it: req.cspNonce
|
|
223
|
+
req.cspNonce = nonce;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const cspValue = buildCsp(cspCfg.directives, nonce);
|
|
227
|
+
res.setHeader('Content-Security-Policy', cspValue);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ── Strict-Transport-Security ────────────────────────────────────────
|
|
231
|
+
// Only set over HTTPS. Express sets req.secure based on protocol.
|
|
232
|
+
if (cfg.strictTransportSecurity !== false) {
|
|
233
|
+
const hsts = cfg.strictTransportSecurity;
|
|
234
|
+
let value = `max-age=${hsts.maxAge}`;
|
|
235
|
+
if (hsts.includeSubDomains) value += '; includeSubDomains';
|
|
236
|
+
if (hsts.preload) value += '; preload';
|
|
237
|
+
// Always set it — if behind a proxy, trust the X-Forwarded-Proto header
|
|
238
|
+
res.setHeader('Strict-Transport-Security', value);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ── X-Frame-Options ──────────────────────────────────────────────────
|
|
242
|
+
if (cfg.xFrameOptions !== false) {
|
|
243
|
+
res.setHeader('X-Frame-Options', cfg.xFrameOptions);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ── X-Content-Type-Options ───────────────────────────────────────────
|
|
247
|
+
if (cfg.xContentTypeOptions !== false) {
|
|
248
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ── Referrer-Policy ──────────────────────────────────────────────────
|
|
252
|
+
if (cfg.referrerPolicy !== false) {
|
|
253
|
+
res.setHeader('Referrer-Policy', cfg.referrerPolicy);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ── Permissions-Policy ───────────────────────────────────────────────
|
|
257
|
+
if (cfg.permissionsPolicy !== false) {
|
|
258
|
+
res.setHeader('Permissions-Policy', buildPermissionsPolicy(cfg.permissionsPolicy));
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
next();
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ── Static factory (convenience) ─────────────────────────────────────────
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Create a SecurityHeaders instance from a config section.
|
|
269
|
+
*
|
|
270
|
+
* SecurityHeaders.from(config.security?.headers)
|
|
271
|
+
*
|
|
272
|
+
* @param {object|false|undefined} config
|
|
273
|
+
* @returns {SecurityHeaders}
|
|
274
|
+
*/
|
|
275
|
+
static from(config) {
|
|
276
|
+
if (config === false) return new SecurityHeaders(false);
|
|
277
|
+
return new SecurityHeaders(config || {});
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
module.exports = SecurityHeaders;
|
package/src/i18n/Translator.js
CHANGED
|
@@ -321,7 +321,15 @@ class Translator {
|
|
|
321
321
|
locale = req.query.lang;
|
|
322
322
|
// Persist to cookie so subsequent requests remember
|
|
323
323
|
if (cookie) {
|
|
324
|
-
|
|
324
|
+
// Locale is a non-sensitive preference value, not a security credential.
|
|
325
|
+
// httpOnly: false is intentional here so client-side JS can read/switch
|
|
326
|
+
// the locale without a round-trip. All other secure defaults still apply.
|
|
327
|
+
res.cookie(cookieName, locale, {
|
|
328
|
+
maxAge: 60 * 60 * 24 * 365,
|
|
329
|
+
httpOnly: false,
|
|
330
|
+
sameSite: 'Lax',
|
|
331
|
+
secure: process.env.NODE_ENV === 'production',
|
|
332
|
+
});
|
|
325
333
|
}
|
|
326
334
|
}
|
|
327
335
|
|
|
@@ -632,4 +640,4 @@ class Translator {
|
|
|
632
640
|
}
|
|
633
641
|
}
|
|
634
642
|
|
|
635
|
-
module.exports = Translator;
|
|
643
|
+
module.exports = Translator;
|