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.
Files changed (57) hide show
  1. package/package.json +3 -2
  2. package/src/admin/Admin.js +122 -38
  3. package/src/admin/ViewContext.js +12 -3
  4. package/src/admin/resources/AdminResource.js +10 -0
  5. package/src/admin/static/admin.css +95 -14
  6. package/src/admin/views/layouts/base.njk +23 -34
  7. package/src/admin/views/pages/detail.njk +16 -5
  8. package/src/admin/views/pages/error.njk +65 -0
  9. package/src/admin/views/pages/list.njk +127 -2
  10. package/src/admin/views/partials/form-scripts.njk +7 -3
  11. package/src/admin/views/partials/form-widget.njk +2 -1
  12. package/src/admin/views/partials/icons.njk +64 -0
  13. package/src/ai/AIManager.js +954 -0
  14. package/src/ai/AITokenBudget.js +250 -0
  15. package/src/ai/PromptGuard.js +216 -0
  16. package/src/ai/agents.js +218 -0
  17. package/src/ai/conversation.js +213 -0
  18. package/src/ai/drivers.js +734 -0
  19. package/src/ai/files.js +249 -0
  20. package/src/ai/media.js +303 -0
  21. package/src/ai/pricing.js +152 -0
  22. package/src/ai/provider_tools.js +114 -0
  23. package/src/ai/types.js +356 -0
  24. package/src/commands/createsuperuser.js +17 -4
  25. package/src/commands/serve.js +2 -4
  26. package/src/container/AppInitializer.js +39 -15
  27. package/src/container/Application.js +31 -1
  28. package/src/core/foundation.js +1 -1
  29. package/src/errors/HttpError.js +32 -16
  30. package/src/facades/AI.js +411 -0
  31. package/src/facades/Hash.js +67 -0
  32. package/src/facades/Process.js +144 -0
  33. package/src/hashing/Hash.js +262 -0
  34. package/src/http/HtmlEscape.js +162 -0
  35. package/src/http/MillasRequest.js +63 -7
  36. package/src/http/MillasResponse.js +70 -4
  37. package/src/http/ResponseDispatcher.js +21 -27
  38. package/src/http/SafeFilePath.js +195 -0
  39. package/src/http/SafeRedirect.js +62 -0
  40. package/src/http/SecurityBootstrap.js +70 -0
  41. package/src/http/helpers.js +40 -125
  42. package/src/http/index.js +10 -1
  43. package/src/http/middleware/CsrfMiddleware.js +258 -0
  44. package/src/http/middleware/RateLimiter.js +314 -0
  45. package/src/http/middleware/SecurityHeaders.js +281 -0
  46. package/src/i18n/Translator.js +10 -2
  47. package/src/logger/LogRedactor.js +247 -0
  48. package/src/logger/Logger.js +1 -1
  49. package/src/logger/formatters/JsonFormatter.js +11 -4
  50. package/src/logger/formatters/PrettyFormatter.js +3 -1
  51. package/src/logger/formatters/SimpleFormatter.js +14 -3
  52. package/src/middleware/ThrottleMiddleware.js +27 -4
  53. package/src/process/Process.js +333 -0
  54. package/src/router/MiddlewareRegistry.js +27 -2
  55. package/src/scaffold/templates.js +3 -0
  56. package/src/validation/Validator.js +348 -607
  57. 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;
@@ -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
- res.cookie(cookieName, locale, { maxAge: 60 * 60 * 24 * 365, httpOnly: false });
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;