@mantiq/core 0.5.21 → 0.5.22

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mantiq/core",
3
- "version": "0.5.21",
3
+ "version": "0.5.22",
4
4
  "description": "Service container, router, middleware, HTTP kernel, config, and exception handler",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -189,9 +189,7 @@ export class Application extends ContainerImpl {
189
189
  providers.push(mod[providerName])
190
190
  }
191
191
  } catch (e) {
192
- if (process.env.APP_DEBUG === 'true') {
193
- console.warn(`[Mantiq] Failed to load provider from ${file}:`, (e as Error)?.message ?? e)
194
- }
192
+ console.warn(`[Mantiq] Failed to load provider from ${file}:`, (e as Error)?.message ?? e)
195
193
  }
196
194
  }
197
195
  } catch {
@@ -250,7 +248,12 @@ export class Application extends ContainerImpl {
250
248
  */
251
249
  async bootProviders(): Promise<void> {
252
250
  for (const provider of this.providers) {
253
- await provider.boot()
251
+ try {
252
+ await provider.boot()
253
+ } catch (e) {
254
+ const name = provider.constructor?.name ?? 'Unknown'
255
+ console.error(`[Mantiq] ${name}.boot() failed:`, (e as Error)?.stack ?? e)
256
+ }
254
257
  }
255
258
  this.booted = true
256
259
  }
@@ -295,9 +298,11 @@ export class Application extends ContainerImpl {
295
298
  await deferredProvider.boot()
296
299
  this.providers.push(deferredProvider)
297
300
  }
298
- // @internal: sync wrapper deferred providers must not have async register/boot
299
- // that relies on other async operations. In practice this is fine.
300
- void boot()
301
+ // Deferred bootcatch and log errors instead of swallowing
302
+ boot().catch((e) => {
303
+ const name = deferredProvider.constructor?.name ?? 'DeferredProvider'
304
+ console.error(`[Mantiq] ${name} deferred boot failed:`, (e as Error)?.stack ?? e)
305
+ })
301
306
  return super.make(abstract)
302
307
  }
303
308
  }
@@ -91,6 +91,13 @@ export class FileCacheStore implements CacheStore {
91
91
  return this.increment(key, -value)
92
92
  }
93
93
 
94
+ /**
95
+ * Store an item in the cache if the key does not already exist.
96
+ *
97
+ * Note: File-based storage cannot guarantee atomicity. Between the existence
98
+ * check and the write, another process could set the key. For truly atomic
99
+ * add semantics, use the Redis or Memcached cache driver.
100
+ */
94
101
  async add(key: string, value: unknown, ttl?: number): Promise<boolean> {
95
102
  if (await this.has(key)) return false
96
103
  await this.put(key, value, ttl)
@@ -55,8 +55,20 @@ export class MemoryCacheStore implements CacheStore {
55
55
  }
56
56
 
57
57
  async add(key: string, value: unknown, ttl?: number): Promise<boolean> {
58
- if (await this.has(key)) return false
59
- await this.put(key, value, ttl)
58
+ // Atomic check-and-set: read the entry directly and check expiry in one step
59
+ // to avoid TOCTOU race between has() and put().
60
+ const existing = this.store.get(key)
61
+ if (existing) {
62
+ if (existing.expiresAt === null || Date.now() <= existing.expiresAt) {
63
+ return false
64
+ }
65
+ // Expired — remove and allow re-add
66
+ this.store.delete(key)
67
+ }
68
+ this.store.set(key, {
69
+ value,
70
+ expiresAt: ttl != null ? Date.now() + ttl * 1000 : null,
71
+ })
60
72
  return true
61
73
  }
62
74
  }
@@ -18,6 +18,10 @@ export interface MantiqRequest {
18
18
  has(...keys: string[]): boolean
19
19
  filled(...keys: string[]): Promise<boolean>
20
20
 
21
+ // ── Body errors ────────────────────────────────────────────────────────
22
+ hasBodyError(): boolean
23
+ bodyError(): Error | null
24
+
21
25
  // ── Headers & metadata ───────────────────────────────────────────────────
22
26
  header(key: string, defaultValue?: string): string | undefined
23
27
  headers(): Record<string, string>
@@ -50,6 +54,9 @@ export interface MantiqRequest {
50
54
  isAuthenticated(): boolean
51
55
  setUser(user: any): void
52
56
 
57
+ // ── FormData ────────────────────────────────────────────────────────────
58
+ formData(): Promise<FormData>
59
+
53
60
  // ── Raw ──────────────────────────────────────────────────────────────────
54
61
  raw(): Request
55
62
  }
@@ -156,9 +156,7 @@ export class Discoverer {
156
156
  mod.default(router)
157
157
  }
158
158
  } catch (e) {
159
- if (process.env.APP_DEBUG === 'true') {
160
- console.warn(`[Mantiq] Failed to load route file ${file}:`, (e as Error)?.message ?? e)
161
- }
159
+ console.warn(`[Mantiq] Failed to load route file ${file}:`, (e as Error)?.message ?? e)
162
160
  }
163
161
  }
164
162
  }
@@ -10,6 +10,21 @@ import type { MantiqRequest } from '../contracts/Request.ts'
10
10
  * - Copy as Markdown button
11
11
  * - Source file context when available
12
12
  */
13
+ /**
14
+ * Security: headers that must never appear on the debug page, even in dev mode.
15
+ * These can contain authentication credentials, session tokens, and CSRF secrets.
16
+ */
17
+ const SENSITIVE_HEADERS = new Set([
18
+ 'authorization',
19
+ 'cookie',
20
+ 'set-cookie',
21
+ 'x-csrf-token',
22
+ 'x-xsrf-token',
23
+ 'proxy-authorization',
24
+ 'x-api-key',
25
+ 'x-auth-token',
26
+ ])
27
+
13
28
  export function renderDevErrorPage(request: MantiqRequest, error: unknown): string {
14
29
  const err = error instanceof Error ? error : new Error(String(error))
15
30
  const stack = err.stack ?? err.message
@@ -51,8 +66,13 @@ export function renderDevErrorPage(request: MantiqRequest, error: unknown): stri
51
66
  })
52
67
  .join('')
53
68
 
69
+ // Security: redact sensitive headers to prevent leaking auth tokens,
70
+ // cookies, and CSRF secrets — even on the dev error page.
54
71
  const headersHtml = Object.entries(request.headers())
55
- .map(([k, v]) => `<tr><td class="td-key">${escapeHtml(k)}</td><td class="td-val">${escapeHtml(v)}</td></tr>`)
72
+ .map(([k, v]) => {
73
+ const redacted = SENSITIVE_HEADERS.has(k.toLowerCase()) ? '********' : v
74
+ return `<tr><td class="td-key">${escapeHtml(k)}</td><td class="td-val">${escapeHtml(redacted)}</td></tr>`
75
+ })
56
76
  .join('')
57
77
 
58
78
  const queryString = request.fullUrl().includes('?') ? request.fullUrl().split('?')[1] : ''
@@ -63,14 +83,13 @@ export function renderDevErrorPage(request: MantiqRequest, error: unknown): stri
63
83
  }).join('')
64
84
  : '<tr><td class="td-val" colspan="2" style="opacity:.5">No query parameters</td></tr>'
65
85
 
66
- // Markdown for clipboard
86
+ // Markdown for clipboard — also redact sensitive headers
67
87
  const markdown = [
68
88
  `# ${err.name}: ${err.message}`,
69
89
  '',
70
90
  `**Status:** ${statusCode}`,
71
91
  `**Method:** ${request.method()}`,
72
92
  `**URL:** ${request.fullUrl()}`,
73
- `**IP:** ${request.ip()}`,
74
93
  `**User Agent:** ${request.userAgent()}`,
75
94
  '',
76
95
  '## Stack Trace',
@@ -81,7 +100,10 @@ export function renderDevErrorPage(request: MantiqRequest, error: unknown): stri
81
100
  '## Request Headers',
82
101
  '| Header | Value |',
83
102
  '|--------|-------|',
84
- ...Object.entries(request.headers()).map(([k, v]) => `| ${k} | ${v} |`),
103
+ ...Object.entries(request.headers()).map(([k, v]) => {
104
+ const redacted = SENSITIVE_HEADERS.has(k.toLowerCase()) ? '********' : v
105
+ return `| ${k} | ${redacted} |`
106
+ }),
85
107
  '',
86
108
  `*Bun ${bunVersion} — MantiqJS*`,
87
109
  ].join('\n')
@@ -454,7 +476,7 @@ export function renderDevErrorPage(request: MantiqRequest, error: unknown): stri
454
476
  <tr><td class="td-key">Method</td><td class="td-val">${escapeHtml(request.method())}</td></tr>
455
477
  <tr><td class="td-key">Path</td><td class="td-val">${escapeHtml(request.path())}</td></tr>
456
478
  <tr><td class="td-key">Full URL</td><td class="td-val">${escapeHtml(request.fullUrl())}</td></tr>
457
- <tr><td class="td-key">IP Address</td><td class="td-val">${escapeHtml(request.ip())}</td></tr>
479
+ <!-- Security: IP address omitted to prevent leaking client addresses in shared debug output -->
458
480
  <tr><td class="td-key">User Agent</td><td class="td-val">${escapeHtml(request.userAgent())}</td></tr>
459
481
  <tr><td class="td-key">Expects JSON</td><td class="td-val">${request.expectsJson() ? 'Yes' : 'No'}</td></tr>
460
482
  <tr><td class="td-key">Authenticated</td><td class="td-val">${request.isAuthenticated() ? 'Yes' : 'No'}</td></tr>
@@ -85,6 +85,7 @@ export class DefaultExceptionHandler implements ExceptionHandler {
85
85
  ): Response {
86
86
  // API routes always get JSON — even in debug mode
87
87
  if (request.expectsJson()) {
88
+ // Security: in production (debug=false), never expose error details
88
89
  const body: Record<string, any> = { error: { message: 'Internal Server Error', status: 500 } }
89
90
  if (debug && err.stack) {
90
91
  body['error']['message'] = err.message
@@ -99,9 +99,42 @@ export class HttpKernel {
99
99
 
100
100
  const request = MantiqRequest.fromBun(bunRequest)
101
101
 
102
+ // Pass the direct connection IP from Bun's server
103
+ const socketAddr = server.requestIP(bunRequest)
104
+ if (socketAddr) {
105
+ request.setConnectionIp(socketAddr.address)
106
+ }
107
+
108
+ // Configure trusted proxies and body size limit from config
109
+ let maxBodySize = 10 * 1024 * 1024 // default 10MB
110
+ try {
111
+ const config = this.container.make(ConfigRepository)
112
+ const proxies = config.get<string[]>('app.trustedProxies', [])
113
+ if (proxies && proxies.length > 0) {
114
+ request.setTrustedProxies(proxies)
115
+ }
116
+ maxBodySize = config.get<number>('app.maxBodySize', maxBodySize)
117
+ } catch {
118
+ // ConfigRepository may not be bound yet — leave defaults
119
+ }
120
+
121
+ // Reject oversized request bodies before reading them (413 Payload Too Large)
122
+ const contentLength = Number(bunRequest.headers.get('content-length'))
123
+ if (contentLength > maxBodySize) {
124
+ return new Response('Payload Too Large', { status: 413 })
125
+ }
126
+
102
127
  try {
103
- // Combine prepend + global + append middleware (deduplicated, preserving order)
104
- const allMiddleware = [...new Set([...this.prependMiddleware, ...this.globalMiddleware, ...this.appendMiddleware])]
128
+ // Combine prepend + global + append middleware
129
+ // Deduplicate exact duplicates but keep parameterized variants (auth:admin vs auth:user)
130
+ const seen = new Set<string>()
131
+ const allMiddleware: string[] = []
132
+ for (const mw of [...this.prependMiddleware, ...this.globalMiddleware, ...this.appendMiddleware]) {
133
+ if (!seen.has(mw)) {
134
+ seen.add(mw)
135
+ allMiddleware.push(mw)
136
+ }
137
+ }
105
138
  const globalClasses = this.resolveMiddlewareList(allMiddleware)
106
139
 
107
140
  const response = await new Pipeline(this.container)
@@ -11,6 +11,9 @@ export class MantiqRequest implements MantiqRequestContract {
11
11
  private authenticatedUser: any = null
12
12
  private cookies: Record<string, string> | null = null
13
13
  private sessionStore: SessionStore | null = null
14
+ private connectionIp: string = '127.0.0.1'
15
+ private trustedProxies: string[] = []
16
+ private _bodyError: Error | null = null
14
17
 
15
18
  constructor(
16
19
  private readonly bunRequest: Request,
@@ -86,6 +89,20 @@ export class MantiqRequest implements MantiqRequestContract {
86
89
  return keys.every((k) => all[k] !== undefined && all[k] !== '' && all[k] !== null)
87
90
  }
88
91
 
92
+ /**
93
+ * Whether a body parsing error occurred (malformed JSON, wrong content-type, etc.).
94
+ */
95
+ hasBodyError(): boolean {
96
+ return this._bodyError !== null
97
+ }
98
+
99
+ /**
100
+ * Return the body parsing error, or null if parsing succeeded.
101
+ */
102
+ bodyError(): Error | null {
103
+ return this._bodyError
104
+ }
105
+
89
106
  // ── Headers & metadata ───────────────────────────────────────────────────
90
107
 
91
108
  header(key: string, defaultValue?: string): string | undefined {
@@ -112,10 +129,28 @@ export class MantiqRequest implements MantiqRequestContract {
112
129
  }
113
130
 
114
131
  ip(): string {
115
- // @internal: Bun doesn't expose IP on Request callers should pass it via middleware if needed
116
- return this.header('x-forwarded-for')?.split(',')[0]?.trim()
117
- ?? this.header('x-real-ip')
118
- ?? '127.0.0.1'
132
+ // Only trust proxy headers when the direct connection comes from a trusted proxy
133
+ if (this.trustedProxies.length > 0 && this.trustedProxies.includes(this.connectionIp)) {
134
+ const forwarded = this.header('x-forwarded-for')?.split(',')[0]?.trim()
135
+ if (forwarded) return forwarded
136
+ const realIp = this.header('x-real-ip')
137
+ if (realIp) return realIp
138
+ }
139
+ return this.connectionIp
140
+ }
141
+
142
+ /**
143
+ * Set the direct connection IP address (from the server/socket).
144
+ */
145
+ setConnectionIp(ip: string): void {
146
+ this.connectionIp = ip
147
+ }
148
+
149
+ /**
150
+ * Configure which proxy IPs are trusted to set X-Forwarded-For / X-Real-IP.
151
+ */
152
+ setTrustedProxies(proxies: string[]): void {
153
+ this.trustedProxies = proxies
119
154
  }
120
155
 
121
156
  userAgent(): string {
@@ -213,6 +248,12 @@ export class MantiqRequest implements MantiqRequestContract {
213
248
  this.authenticatedUser = user
214
249
  }
215
250
 
251
+ // ── FormData ────────────────────────────────────────────────────────────
252
+
253
+ async formData(): Promise<FormData> {
254
+ return this.bunRequest.clone().formData() as Promise<FormData>
255
+ }
256
+
216
257
  // ── Raw ──────────────────────────────────────────────────────────────────
217
258
 
218
259
  raw(): Request {
@@ -250,8 +291,12 @@ export class MantiqRequest implements MantiqRequestContract {
250
291
  }
251
292
  }
252
293
  }
253
- } catch {
254
- // Body parsing failed — leave parsedBody as empty object
294
+ } catch (error) {
295
+ // Body parsing failed — store the error for inspection
296
+ this._bodyError = error instanceof Error ? error : new Error(String(error))
297
+ if (typeof process !== 'undefined' && process.env?.APP_DEBUG === 'true') {
298
+ console.warn('[Mantiq] Body parse error:', this._bodyError.message)
299
+ }
255
300
  }
256
301
  }
257
302
  }
@@ -19,7 +19,36 @@ export class MantiqResponse {
19
19
  })
20
20
  }
21
21
 
22
+ /**
23
+ * Validate that a redirect URL is safe.
24
+ * Security: reject protocol-relative URLs (//evil.com), javascript:, data:,
25
+ * and other dangerous schemes that could redirect users to malicious sites.
26
+ * Only relative paths and http(s) URLs on the same origin are allowed.
27
+ */
28
+ private static validateRedirectUrl(url: string): void {
29
+ const trimmed = url.trim()
30
+
31
+ // Reject protocol-relative URLs (//evil.com)
32
+ if (trimmed.startsWith('//')) {
33
+ throw new Error('Unsafe redirect URL: protocol-relative URLs are not allowed')
34
+ }
35
+
36
+ // Reject dangerous schemes
37
+ const lower = trimmed.toLowerCase()
38
+ if (lower.startsWith('javascript:') || lower.startsWith('data:') || lower.startsWith('vbscript:')) {
39
+ throw new Error('Unsafe redirect URL: dangerous scheme detected')
40
+ }
41
+
42
+ // If it looks like an absolute URL, only allow http(s)
43
+ if (/^[a-z][a-z0-9+\-.]*:/i.test(trimmed)) {
44
+ if (!lower.startsWith('http://') && !lower.startsWith('https://')) {
45
+ throw new Error('Unsafe redirect URL: only http and https schemes are allowed')
46
+ }
47
+ }
48
+ }
49
+
22
50
  static redirect(url: string, status: number = 302): Response {
51
+ MantiqResponse.validateRedirectUrl(url)
23
52
  return new Response(null, {
24
53
  status,
25
54
  headers: { Location: url },
@@ -37,15 +66,37 @@ export class MantiqResponse {
37
66
  return new Response(stream)
38
67
  }
39
68
 
69
+ /**
70
+ * Security: sanitize Content-Disposition filename to prevent header injection.
71
+ * - Strip CR/LF to block header injection via newlines
72
+ * - Escape double quotes to prevent breaking out of the quoted filename
73
+ * - Strip path separators to prevent directory traversal
74
+ * - Use RFC 6266 filename* parameter for non-ASCII characters
75
+ */
76
+ private static sanitizeFilename(filename: string): string {
77
+ return filename
78
+ .replace(/[\r\n]/g, '') // Strip newlines — prevents header injection
79
+ .replace(/[/\\]/g, '') // Strip path separators — prevents traversal
80
+ .replace(/"/g, '\\"') // Escape quotes — prevents breaking out of quoted string
81
+ }
82
+
40
83
  static download(
41
84
  content: Uint8Array | string,
42
85
  filename: string,
43
86
  mimeType?: string,
44
87
  ): Response {
88
+ const sanitized = MantiqResponse.sanitizeFilename(filename)
89
+
90
+ // RFC 6266: use filename* with UTF-8 encoding for non-ASCII filenames
91
+ const isAscii = /^[\x20-\x7E]+$/.test(sanitized)
92
+ const disposition = isAscii
93
+ ? `attachment; filename="${sanitized}"`
94
+ : `attachment; filename="${sanitized}"; filename*=UTF-8''${encodeURIComponent(sanitized)}`
95
+
45
96
  return new Response(content, {
46
97
  headers: {
47
98
  'Content-Type': mimeType ?? 'application/octet-stream',
48
- 'Content-Disposition': `attachment; filename="${filename}"`,
99
+ 'Content-Disposition': disposition,
49
100
  },
50
101
  })
51
102
  }
@@ -100,6 +151,8 @@ export class ResponseBuilder implements MantiqResponseBuilder {
100
151
  }
101
152
 
102
153
  redirect(url: string): Response {
154
+ // Security: reuse the same URL validation as MantiqResponse.redirect()
155
+ MantiqResponse['validateRedirectUrl'](url)
103
156
  const headers = new Headers({ Location: url, ...this.responseHeaders })
104
157
  for (const c of this.cookieStrings) headers.append('Set-Cookie', c)
105
158
  return new Response(null, { status: this.statusExplicitlySet ? this.statusCode : 302, headers })
@@ -35,7 +35,7 @@ export class UploadedFile {
35
35
  * @param options.disk - Storage disk (currently only local filesystem)
36
36
  */
37
37
  async store(path: string, _options?: { disk?: string }): Promise<string> {
38
- const filename = `${Date.now()}_${this.file.name}`
38
+ const filename = `${Date.now()}_${sanitizeFilename(this.file.name)}`
39
39
  const fullPath = `${path}/${filename}`
40
40
  const bytes = await this.file.arrayBuffer()
41
41
  await Bun.write(fullPath, bytes)
@@ -54,3 +54,26 @@ export class UploadedFile {
54
54
  return this.file.stream()
55
55
  }
56
56
  }
57
+
58
+ /**
59
+ * Sanitize a filename to prevent path traversal attacks.
60
+ * Strips directory separators and ".." sequences, returning only the basename.
61
+ */
62
+ function sanitizeFilename(name: string): string {
63
+ // Extract basename — strip any path separators (Unix and Windows)
64
+ let safe = name.split('/').pop()!
65
+ safe = safe.split('\\').pop()!
66
+
67
+ // Remove any remaining ".." sequences
68
+ safe = safe.replace(/\.\./g, '')
69
+
70
+ // Remove control characters and null bytes
71
+ safe = safe.replace(/[\x00-\x1f]/g, '')
72
+
73
+ // Fallback if the name is empty after sanitization
74
+ if (!safe || safe === '.' || safe === '..') {
75
+ safe = 'unnamed'
76
+ }
77
+
78
+ return safe
79
+ }
package/src/index.ts CHANGED
@@ -56,6 +56,8 @@ export { VerifyCsrfToken } from './middleware/VerifyCsrfToken.ts'
56
56
  export { RateLimiter, MemoryStore } from './rateLimit/RateLimiter.ts'
57
57
  export type { RateLimitConfig, RateLimitStore, LimiterResolver } from './rateLimit/RateLimiter.ts'
58
58
  export { ThrottleRequests, getDefaultRateLimiter, setDefaultRateLimiter } from './rateLimit/ThrottleRequests.ts'
59
+ export { SecureHeaders } from './middleware/SecureHeaders.ts'
60
+ export { Enum } from './support/Enum.ts'
59
61
  export { WebSocketKernel } from './websocket/WebSocketKernel.ts'
60
62
  export { DefaultExceptionHandler } from './exceptions/Handler.ts'
61
63
  export { CoreServiceProvider } from './providers/CoreServiceProvider.ts'
@@ -76,7 +76,15 @@ export class CorsMiddleware implements Middleware {
76
76
  private setOriginHeader(headers: Headers, requestOrigin: string): void {
77
77
  const { origin } = this.config
78
78
  if (origin === '*') {
79
- headers.set('Access-Control-Allow-Origin', '*')
79
+ if (this.config.credentials) {
80
+ // CORS spec forbids Access-Control-Allow-Origin: * with credentials.
81
+ // Reflect the request's Origin header instead; if absent, omit CORS headers entirely.
82
+ if (!requestOrigin) return
83
+ headers.set('Access-Control-Allow-Origin', requestOrigin)
84
+ headers.set('Vary', 'Origin')
85
+ } else {
86
+ headers.set('Access-Control-Allow-Origin', '*')
87
+ }
80
88
  } else if (Array.isArray(origin)) {
81
89
  if (origin.includes(requestOrigin)) {
82
90
  headers.set('Access-Control-Allow-Origin', requestOrigin)
@@ -47,7 +47,10 @@ export class EncryptCookies implements Middleware {
47
47
  const decoded = decodeURIComponent(value)
48
48
  decrypted[name] = await this.encrypter.decrypt(decoded)
49
49
  } catch {
50
- // Can't decrypt — skip this cookie (expired key, tampered, etc.)
50
+ // Can't decrypt — expired key, tampered, or wrong format
51
+ if (process.env.APP_DEBUG === 'true') {
52
+ console.warn(`[Mantiq] Failed to decrypt cookie "${name}" — using raw value`)
53
+ }
51
54
  decrypted[name] = value
52
55
  }
53
56
  }
@@ -0,0 +1,72 @@
1
+ import type { Middleware, NextFunction } from '../contracts/Middleware.ts'
2
+ import type { MantiqRequest } from '../contracts/Request.ts'
3
+
4
+ /**
5
+ * Security headers middleware.
6
+ *
7
+ * Adds common security headers to every response to protect against
8
+ * clickjacking, MIME sniffing, XSS, and downgrade attacks.
9
+ *
10
+ * Register as 'secure-headers' alias and add to middleware groups:
11
+ * middlewareGroups: {
12
+ * web: ['cors', 'secure-headers', 'encrypt.cookies', 'session', 'csrf'],
13
+ * }
14
+ */
15
+ export class SecureHeaders implements Middleware {
16
+ async handle(request: MantiqRequest, next: NextFunction): Promise<Response> {
17
+ const response = await next()
18
+ const headers = new Headers(response.headers)
19
+
20
+ // Prevent clickjacking — page cannot be embedded in iframes
21
+ if (!headers.has('X-Frame-Options')) {
22
+ headers.set('X-Frame-Options', 'SAMEORIGIN')
23
+ }
24
+
25
+ // Prevent MIME type sniffing — browser must respect Content-Type
26
+ if (!headers.has('X-Content-Type-Options')) {
27
+ headers.set('X-Content-Type-Options', 'nosniff')
28
+ }
29
+
30
+ // Enable browser XSS filter (legacy, but harmless)
31
+ if (!headers.has('X-XSS-Protection')) {
32
+ headers.set('X-XSS-Protection', '1; mode=block')
33
+ }
34
+
35
+ // Enforce HTTPS in production
36
+ if (!headers.has('Strict-Transport-Security')) {
37
+ if (process.env.APP_ENV === 'production') {
38
+ headers.set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains')
39
+ }
40
+ }
41
+
42
+ // Control what the browser is allowed to load.
43
+ // Security: do NOT include 'unsafe-inline' or 'unsafe-eval' — they defeat
44
+ // the purpose of CSP. Use nonce-based script/style loading instead.
45
+ // The nonce is generated per-request; pass it to templates via request.cspNonce.
46
+ if (!headers.has('Content-Security-Policy')) {
47
+ const nonce = crypto.randomUUID().replace(/-/g, '')
48
+ // Attach nonce to request so templates/views can reference it
49
+ ;(request as any).cspNonce = nonce
50
+ headers.set(
51
+ 'Content-Security-Policy',
52
+ `default-src 'self'; script-src 'self' 'nonce-${nonce}'; style-src 'self' 'nonce-${nonce}'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' ws: wss:; object-src 'none'; base-uri 'self'; form-action 'self'`,
53
+ )
54
+ }
55
+
56
+ // Prevent leaking referer to external sites
57
+ if (!headers.has('Referrer-Policy')) {
58
+ headers.set('Referrer-Policy', 'strict-origin-when-cross-origin')
59
+ }
60
+
61
+ // Disable browser features you don't use
62
+ if (!headers.has('Permissions-Policy')) {
63
+ headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()')
64
+ }
65
+
66
+ return new Response(response.body, {
67
+ status: response.status,
68
+ statusText: response.statusText,
69
+ headers,
70
+ })
71
+ }
72
+ }
@@ -10,6 +10,7 @@ import { StartSession } from '../middleware/StartSession.ts'
10
10
  import { EncryptCookies } from '../middleware/EncryptCookies.ts'
11
11
  import { VerifyCsrfToken } from '../middleware/VerifyCsrfToken.ts'
12
12
  import { ThrottleRequests } from '../rateLimit/ThrottleRequests.ts'
13
+ import { SecureHeaders } from '../middleware/SecureHeaders.ts'
13
14
  import { ROUTER } from '../helpers/route.ts'
14
15
  import { ENCRYPTER } from '../helpers/encrypt.ts'
15
16
  import { AesEncrypter } from '../encryption/Encrypter.ts'
@@ -80,6 +81,7 @@ export class CoreServiceProvider extends ServiceProvider {
80
81
 
81
82
  // Rate limiting — zero-config, uses shared in-memory store
82
83
  this.app.singleton(ThrottleRequests, () => new ThrottleRequests())
84
+ this.app.singleton(SecureHeaders, () => new SecureHeaders())
83
85
 
84
86
  // HTTP kernel — singleton, depends on Router + ExceptionHandler + WsKernel
85
87
  this.app.singleton(HttpKernel, (c) => {
@@ -96,6 +98,10 @@ export class CoreServiceProvider extends ServiceProvider {
96
98
  if (appKey) {
97
99
  const encrypter = await AesEncrypter.fromAppKey(appKey)
98
100
  this.app.instance(ENCRYPTER, encrypter)
101
+
102
+ // Give WebSocketKernel the encrypter so it can decrypt cookies on upgrade
103
+ const wsKernel = this.app.make(WebSocketKernel)
104
+ wsKernel.setEncrypter(encrypter)
99
105
  }
100
106
 
101
107
  // ── Auto-register middleware aliases on HttpKernel ─────────────────────
@@ -106,6 +112,7 @@ export class CoreServiceProvider extends ServiceProvider {
106
112
  kernel.registerMiddleware('encrypt.cookies', EncryptCookies)
107
113
  kernel.registerMiddleware('session', StartSession)
108
114
  kernel.registerMiddleware('csrf', VerifyCsrfToken)
115
+ kernel.registerMiddleware('secure-headers', SecureHeaders)
109
116
 
110
117
  // Register middleware groups from config
111
118
  const configRepo = this.app.make(ConfigRepository)
@@ -40,7 +40,8 @@ export class SessionStore {
40
40
  */
41
41
  async save(): Promise<void> {
42
42
  await this.handler.write(this.id, JSON.stringify(this.attributes))
43
- this.started = false
43
+ // Keep started=true so the session remains writable after save.
44
+ // Middleware may still need to write to the session after saving.
44
45
  }
45
46
 
46
47
  // ── Getters & setters ───────────────────────────────────────────────────
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Base Enum class — Laravel-style backed enums for TypeScript.
3
+ *
4
+ * @example
5
+ * class UserStatus extends Enum {
6
+ * static Active = new UserStatus('active')
7
+ * static Inactive = new UserStatus('inactive')
8
+ * static Banned = new UserStatus('banned')
9
+ * }
10
+ *
11
+ * UserStatus.from('active') // → UserStatus.Active
12
+ * UserStatus.values() // → ['active', 'inactive', 'banned']
13
+ * UserStatus.cases() // → [Active, Inactive, Banned]
14
+ * UserStatus.Active.value // → 'active'
15
+ * UserStatus.Active.label // → 'Active'
16
+ * UserStatus.Active.is(status) // → boolean
17
+ */
18
+ export class Enum {
19
+ readonly value: string | number
20
+
21
+ constructor(value: string | number) {
22
+ this.value = value
23
+ }
24
+
25
+ /** Human-readable label — derived from the static property name. */
26
+ get label(): string {
27
+ const ctor = this.constructor as typeof Enum
28
+ for (const [key, val] of Object.entries(ctor)) {
29
+ if (val === this) {
30
+ // Convert PascalCase/camelCase to spaced: 'InProgress' → 'In Progress'
31
+ return key.replace(/([a-z])([A-Z])/g, '$1 $2')
32
+ }
33
+ }
34
+ return String(this.value)
35
+ }
36
+
37
+ /** Check if this enum equals another value (enum instance, string, or number). */
38
+ is(other: Enum | string | number): boolean {
39
+ if (other instanceof Enum) return this.value === other.value
40
+ return this.value === other
41
+ }
42
+
43
+ /** Check if this enum does NOT equal another value. */
44
+ isNot(other: Enum | string | number): boolean {
45
+ return !this.is(other)
46
+ }
47
+
48
+ /** String representation — returns the raw value. */
49
+ toString(): string {
50
+ return String(this.value)
51
+ }
52
+
53
+ /** JSON serialization — returns the raw value. */
54
+ toJSON(): string | number {
55
+ return this.value
56
+ }
57
+
58
+ // ── Static methods (called on the subclass) ────────────────────────────
59
+
60
+ /** Get all enum instances. */
61
+ static cases(): Enum[] {
62
+ return Object.values(this).filter((v) => v instanceof Enum)
63
+ }
64
+
65
+ /** Get all raw values. */
66
+ static values(): (string | number)[] {
67
+ return this.cases().map((c) => c.value)
68
+ }
69
+
70
+ /** Get all labels. */
71
+ static labels(): string[] {
72
+ return this.cases().map((c) => c.label)
73
+ }
74
+
75
+ /** Get an enum instance from a raw value. Throws if not found. */
76
+ static from(value: string | number): Enum {
77
+ const found = this.tryFrom(value)
78
+ if (!found) throw new Error(`"${value}" is not a valid ${this.name} value. Valid: ${this.values().join(', ')}`)
79
+ return found
80
+ }
81
+
82
+ /** Get an enum instance from a raw value. Returns null if not found. */
83
+ static tryFrom(value: string | number): Enum | null {
84
+ return this.cases().find((c) => c.value === value) ?? null
85
+ }
86
+
87
+ /** Check if a value is valid for this enum. */
88
+ static has(value: string | number): boolean {
89
+ return this.values().includes(value)
90
+ }
91
+
92
+ /** Get a map of value → label for select dropdowns, etc. */
93
+ static options(): Array<{ value: string | number; label: string }> {
94
+ return this.cases().map((c) => ({ value: c.value, label: c.label }))
95
+ }
96
+ }
@@ -1,5 +1,7 @@
1
1
  import type { WebSocketHandler } from './WebSocketContext.ts'
2
+ import type { AesEncrypter } from '../encryption/Encrypter.ts'
2
3
  import { MantiqRequest } from '../http/Request.ts'
4
+ import { parseCookies } from '../http/Cookie.ts'
3
5
 
4
6
  /**
5
7
  * Handles WebSocket upgrade detection and lifecycle delegation.
@@ -9,6 +11,7 @@ import { MantiqRequest } from '../http/Request.ts'
9
11
  */
10
12
  export class WebSocketKernel {
11
13
  private handler: WebSocketHandler | null = null
14
+ private encrypter: AesEncrypter | null = null
12
15
 
13
16
  /**
14
17
  * Called by @mantiq/realtime to register its WebSocket handler.
@@ -17,6 +20,14 @@ export class WebSocketKernel {
17
20
  this.handler = handler
18
21
  }
19
22
 
23
+ /**
24
+ * Inject the encrypter so WebSocket upgrades can decrypt cookies.
25
+ * Called during CoreServiceProvider boot.
26
+ */
27
+ setEncrypter(encrypter: AesEncrypter): void {
28
+ this.encrypter = encrypter
29
+ }
30
+
20
31
  /**
21
32
  * Called by HttpKernel when an upgrade request is detected.
22
33
  */
@@ -29,6 +40,13 @@ export class WebSocketKernel {
29
40
  }
30
41
 
31
42
  const mantiqRequest = MantiqRequest.fromBun(request)
43
+
44
+ // Decrypt cookies — WebSocket upgrades bypass the middleware pipeline,
45
+ // so EncryptCookies never runs. Manually decrypt here.
46
+ if (this.encrypter) {
47
+ await this.decryptCookies(mantiqRequest)
48
+ }
49
+
32
50
  const context = await this.handler.onUpgrade(mantiqRequest)
33
51
 
34
52
  if (!context) {
@@ -57,4 +75,29 @@ export class WebSocketKernel {
57
75
  drain: (ws: any) => h?.drain(ws),
58
76
  }
59
77
  }
78
+
79
+ // ── Private ───────────────────────────────────────────────────────────────
80
+
81
+ private async decryptCookies(request: MantiqRequest): Promise<void> {
82
+ const cookieHeader = request.header('cookie')
83
+ if (!cookieHeader) return
84
+
85
+ const cookies = parseCookies(cookieHeader)
86
+ const decrypted: Record<string, string> = {}
87
+ const except = ['XSRF-TOKEN']
88
+
89
+ for (const [name, value] of Object.entries(cookies)) {
90
+ if (except.includes(name)) {
91
+ decrypted[name] = value
92
+ continue
93
+ }
94
+ try {
95
+ decrypted[name] = await this.encrypter!.decrypt(value)
96
+ } catch {
97
+ decrypted[name] = value
98
+ }
99
+ }
100
+
101
+ request.setCookies(decrypted)
102
+ }
60
103
  }