@mantiq/core 0.5.23 → 0.6.0

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.23",
3
+ "version": "0.6.0",
4
4
  "description": "Service container, router, middleware, HTTP kernel, config, and exception handler",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -298,14 +298,22 @@ export class Application extends ContainerImpl {
298
298
  /**
299
299
  * Boot all registered (non-deferred) providers.
300
300
  * Called after ALL providers have been registered.
301
+ *
302
+ * Errors are re-thrown with descriptive messages so they surface
303
+ * immediately rather than being silently swallowed.
301
304
  */
302
305
  async bootProviders(): Promise<void> {
303
306
  for (const provider of this.providers) {
307
+ const name = provider.constructor?.name ?? 'Unknown'
304
308
  try {
305
309
  await provider.boot()
306
310
  } catch (e) {
307
- const name = provider.constructor?.name ?? 'Unknown'
308
- console.error(`[Mantiq] ${name}.boot() failed:`, (e as Error)?.stack ?? e)
311
+ // Re-throw with context so the developer knows which provider failed.
312
+ // Previously errors were only logged, masking critical boot failures.
313
+ throw new Error(
314
+ `[Mantiq] ${name}.boot() failed: ${(e as Error)?.message ?? e}`,
315
+ { cause: e },
316
+ )
309
317
  }
310
318
  }
311
319
  this.booted = true
@@ -330,6 +338,10 @@ export class Application extends ContainerImpl {
330
338
  /**
331
339
  * Override make() to handle deferred provider loading.
332
340
  * If a binding isn't found in the container, check deferred providers.
341
+ *
342
+ * Deferred providers are registered and booted synchronously where possible.
343
+ * If register() or boot() return a Promise, this method will throw — callers
344
+ * must use makeAsync() for providers that require async initialization.
333
345
  */
334
346
  override make<T>(abstract: Bindable<T>): T {
335
347
  try {
@@ -345,17 +357,74 @@ export class Application extends ContainerImpl {
345
357
  for (const [key, p] of this.deferredProviders.entries()) {
346
358
  if (p === deferredProvider) this.deferredProviders.delete(key)
347
359
  }
348
- // Register + boot the deferred provider now
349
- const boot = async () => {
360
+
361
+ const providerName = deferredProvider.constructor?.name ?? 'DeferredProvider'
362
+
363
+ // Register the deferred provider synchronously. If register()
364
+ // returns a Promise, it means the provider requires async init —
365
+ // we cannot silently fire-and-forget because make() would return
366
+ // before the binding is registered.
367
+ const registerResult = deferredProvider.register()
368
+ if (registerResult && typeof (registerResult as any).then === 'function') {
369
+ throw new Error(
370
+ `[Mantiq] ${providerName}.register() is async but was triggered via synchronous make(). ` +
371
+ `Use await app.makeAsync(${String((abstract as any)?.name ?? abstract)}) instead.`,
372
+ )
373
+ }
374
+
375
+ // Boot the deferred provider synchronously.
376
+ const bootResult = deferredProvider.boot()
377
+ if (bootResult && typeof (bootResult as any).then === 'function') {
378
+ throw new Error(
379
+ `[Mantiq] ${providerName}.boot() is async but was triggered via synchronous make(). ` +
380
+ `Use await app.makeAsync(${String((abstract as any)?.name ?? abstract)}) instead.`,
381
+ )
382
+ }
383
+
384
+ this.providers.push(deferredProvider)
385
+ return super.make(abstract)
386
+ }
387
+ }
388
+ throw err
389
+ }
390
+ }
391
+
392
+ /**
393
+ * Async version of make() that properly awaits deferred provider boot.
394
+ *
395
+ * Use this when the deferred provider's register() or boot() may be async.
396
+ * For synchronous providers, make() is sufficient and faster.
397
+ */
398
+ async makeAsync<T>(abstract: Bindable<T>): Promise<T> {
399
+ try {
400
+ return super.make(abstract)
401
+ } catch (err) {
402
+ if (
403
+ err instanceof ContainerResolutionError &&
404
+ err.reason === 'not_bound'
405
+ ) {
406
+ const deferredProvider = this.deferredProviders.get(abstract)
407
+ if (deferredProvider) {
408
+ // Remove from deferred map so we don't loop
409
+ for (const [key, p] of this.deferredProviders.entries()) {
410
+ if (p === deferredProvider) this.deferredProviders.delete(key)
411
+ }
412
+
413
+ const providerName = deferredProvider.constructor?.name ?? 'DeferredProvider'
414
+
415
+ // Await register + boot so the binding is fully available
416
+ // before we resolve it. Errors propagate to the caller.
417
+ try {
350
418
  await deferredProvider.register()
351
419
  await deferredProvider.boot()
352
- this.providers.push(deferredProvider)
420
+ } catch (e) {
421
+ throw new Error(
422
+ `[Mantiq] ${providerName} deferred boot failed: ${(e as Error)?.message ?? e}`,
423
+ { cause: e },
424
+ )
353
425
  }
354
- // Deferred boot — catch and log errors instead of swallowing
355
- boot().catch((e) => {
356
- const name = deferredProvider.constructor?.name ?? 'DeferredProvider'
357
- console.error(`[Mantiq] ${name} deferred boot failed:`, (e as Error)?.stack ?? e)
358
- })
426
+
427
+ this.providers.push(deferredProvider)
359
428
  return super.make(abstract)
360
429
  }
361
430
  }
@@ -81,10 +81,42 @@ export class FileCacheStore implements CacheStore {
81
81
  }
82
82
  }
83
83
 
84
+ /**
85
+ * Fix #194: Atomic increment using write-to-temp + rename to prevent
86
+ * TOCTOU race conditions where concurrent increments could lose updates.
87
+ */
84
88
  async increment(key: string, value = 1): Promise<number> {
85
- const current = await this.get<number>(key)
86
- const newValue = (current ?? 0) + value
87
- await this.put(key, newValue)
89
+ await this.ensureDirectory()
90
+ const targetPath = this.path(key)
91
+ const file = Bun.file(targetPath)
92
+
93
+ let current = 0
94
+ if (await file.exists()) {
95
+ try {
96
+ const payload: FileCachePayload = await file.json()
97
+ if (payload.expiresAt !== null && Date.now() > payload.expiresAt) {
98
+ // Expired — treat as 0
99
+ } else {
100
+ current = (payload.value as number) ?? 0
101
+ }
102
+ } catch {
103
+ // Corrupted file — start from 0
104
+ }
105
+ }
106
+
107
+ const newValue = current + value
108
+
109
+ // Atomic write: temp file + rename to avoid partial reads by concurrent operations
110
+ const tmpPath = join(this.directory, `_tmp_${randomBytes(8).toString('hex')}.cache`)
111
+ const payload: FileCachePayload = { value: newValue, expiresAt: null }
112
+ await Bun.write(tmpPath, JSON.stringify(payload))
113
+
114
+ try {
115
+ await rename(tmpPath, targetPath)
116
+ } catch {
117
+ try { await rm(tmpPath) } catch { /* ignore */ }
118
+ }
119
+
88
120
  return newValue
89
121
  }
90
122
 
@@ -43,6 +43,14 @@ export class MemoryCacheStore implements CacheStore {
43
43
  this.store.clear()
44
44
  }
45
45
 
46
+ /**
47
+ * Increment a cached numeric value.
48
+ *
49
+ * Note (#194): This get-then-put is safe in single-threaded Bun because
50
+ * there is no I/O between the read and write — the event loop cannot
51
+ * yield to another task mid-execution. If Bun gains multi-threaded
52
+ * workers sharing memory, this would need a lock or atomic operation.
53
+ */
46
54
  async increment(key: string, value = 1): Promise<number> {
47
55
  const current = await this.get<number>(key)
48
56
  const newValue = (current ?? 0) + value
@@ -8,6 +8,11 @@ import { UnauthorizedError } from '../errors/UnauthorizedError.ts'
8
8
  import { MantiqResponse } from '../http/Response.ts'
9
9
  import { renderDevErrorPage } from './DevErrorPage.ts'
10
10
 
11
+ /** Security: escape HTML special characters to prevent XSS in error pages. */
12
+ function escapeHtml(s: string): string {
13
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;')
14
+ }
15
+
11
16
  export class DefaultExceptionHandler implements ExceptionHandler {
12
17
  dontReport: Constructor<Error>[] = [
13
18
  NotFoundError,
@@ -102,12 +107,15 @@ export class DefaultExceptionHandler implements ExceptionHandler {
102
107
  }
103
108
 
104
109
  private genericHtmlPage(status: number, message: string): string {
110
+ // Security: escape message to prevent XSS — error messages may contain
111
+ // user-controlled input (e.g., URL paths, query parameters).
112
+ const safeMessage = escapeHtml(message)
105
113
  return `<!DOCTYPE html>
106
114
  <html lang="en">
107
115
  <head>
108
116
  <meta charset="UTF-8">
109
117
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
110
- <title>${status} ${message}</title>
118
+ <title>${status} ${safeMessage}</title>
111
119
  <style>
112
120
  body { font-family: system-ui, sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f7fafc; color: #2d3748; }
113
121
  .box { text-align: center; }
@@ -118,7 +126,7 @@ export class DefaultExceptionHandler implements ExceptionHandler {
118
126
  <body>
119
127
  <div class="box">
120
128
  <h1>${status}</h1>
121
- <p>${message}</p>
129
+ <p>${safeMessage}</p>
122
130
  </div>
123
131
  </body>
124
132
  </html>`
@@ -24,12 +24,26 @@ export class CorsMiddleware implements Middleware {
24
24
  const defaultOrigin = appUrl || '*'
25
25
  const defaultCredentials = !!appUrl
26
26
 
27
+ let origin = configRepo?.get('cors.origin', defaultOrigin) ?? defaultOrigin
28
+ let credentials = configRepo?.get('cors.credentials', defaultCredentials) ?? defaultCredentials
29
+
30
+ // Security: origin='*' with credentials=true is dangerous — it effectively
31
+ // disables same-origin policy by reflecting any request origin. Force
32
+ // credentials to false when origin is a wildcard.
33
+ if (origin === '*' && credentials) {
34
+ console.warn(
35
+ '[Mantiq] CORS: origin="*" with credentials=true is insecure. ' +
36
+ 'Forcing credentials=false. Set an explicit origin to use credentials.',
37
+ )
38
+ credentials = false
39
+ }
40
+
27
41
  this.config = {
28
- origin: configRepo?.get('cors.origin', defaultOrigin) ?? defaultOrigin,
42
+ origin,
29
43
  methods: configRepo?.get('cors.methods', ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS']) ?? ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
30
44
  allowedHeaders: configRepo?.get('cors.allowedHeaders', ['Content-Type', 'Authorization', 'X-Requested-With', 'X-CSRF-TOKEN', 'X-XSRF-TOKEN', 'X-Mantiq']) ?? ['Content-Type', 'Authorization', 'X-Requested-With', 'X-CSRF-TOKEN', 'X-XSRF-TOKEN', 'X-Mantiq'],
31
45
  exposedHeaders: configRepo?.get('cors.exposedHeaders', ['X-Heartbeat']) ?? ['X-Heartbeat'],
32
- credentials: configRepo?.get('cors.credentials', defaultCredentials) ?? defaultCredentials,
46
+ credentials,
33
47
  maxAge: configRepo?.get('cors.maxAge', 7200) ?? 7200,
34
48
  }
35
49
  }
@@ -76,15 +90,11 @@ export class CorsMiddleware implements Middleware {
76
90
  private setOriginHeader(headers: Headers, requestOrigin: string): void {
77
91
  const { origin } = this.config
78
92
  if (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
- }
93
+ // When origin='*', always use the literal wildcard. The constructor
94
+ // ensures credentials is false when origin='*', so this is spec-safe.
95
+ // We never reflect an arbitrary request origin that would defeat
96
+ // same-origin policy when combined with credentials.
97
+ headers.set('Access-Control-Allow-Origin', '*')
88
98
  } else if (Array.isArray(origin)) {
89
99
  if (origin.includes(requestOrigin)) {
90
100
  headers.set('Access-Control-Allow-Origin', requestOrigin)
@@ -97,8 +97,9 @@ export class EncryptCookies implements Middleware {
97
97
 
98
98
  headers.append('Set-Cookie', newSetCookie)
99
99
  } catch {
100
- // If encryption fails, send unencrypted (shouldn't happen with valid key)
101
- headers.append('Set-Cookie', setCookie)
100
+ // Security: drop the cookie entirely rather than sending it unencrypted.
101
+ // Sending plaintext cookies would expose sensitive data on the wire.
102
+ console.warn(`[Mantiq] Failed to encrypt cookie "${name}" — dropping cookie to prevent plaintext exposure.`)
102
103
  }
103
104
  }
104
105
 
@@ -55,6 +55,16 @@ export class StartSession implements Middleware {
55
55
  // Save session
56
56
  await session.save()
57
57
 
58
+ // Probabilistic garbage collection: ~2% chance per request to clean up expired sessions.
59
+ // This avoids the need for a dedicated cron job while keeping overhead minimal.
60
+ if (Math.random() < 0.02) {
61
+ const lifetime = config.lifetime * 60 // convert minutes to seconds
62
+ handler.gc(lifetime).catch(() => {
63
+ // GC failures are non-critical — log and continue
64
+ console.warn('[Mantiq] Session garbage collection failed.')
65
+ })
66
+ }
67
+
58
68
  // Attach cookie to response
59
69
  return this.addCookieToResponse(response, session, config)
60
70
  }
@@ -1,5 +1,6 @@
1
1
  import type { Middleware, NextFunction } from '../contracts/Middleware.ts'
2
2
  import type { MantiqRequest } from '../contracts/Request.ts'
3
+ import { timingSafeEqual as cryptoTimingSafeEqual } from 'node:crypto'
3
4
  import { TokenMismatchError } from '../errors/TokenMismatchError.ts'
4
5
  import { AesEncrypter } from '../encryption/Encrypter.ts'
5
6
  import { serializeCookie } from '../http/Cookie.ts'
@@ -113,17 +114,21 @@ export class VerifyCsrfToken implements Middleware {
113
114
 
114
115
  /**
115
116
  * Constant-time string comparison to prevent timing attacks on token verification.
117
+ * Uses node:crypto's timingSafeEqual which handles length-mismatch internally
118
+ * without leaking token length via timing side-channels.
116
119
  */
117
120
  function timingSafeEqual(a: string, b: string): boolean {
118
- if (a.length !== b.length) return false
119
-
120
121
  const encoder = new TextEncoder()
121
122
  const bufA = encoder.encode(a)
122
123
  const bufB = encoder.encode(b)
123
124
 
124
- let result = 0
125
- for (let i = 0; i < bufA.length; i++) {
126
- result |= bufA[i]! ^ bufB[i]!
127
- }
128
- return result === 0
125
+ // Pad to equal length so we never leak the expected token length via an early return.
126
+ const maxLen = Math.max(bufA.length, bufB.length)
127
+ const paddedA = new Uint8Array(maxLen)
128
+ const paddedB = new Uint8Array(maxLen)
129
+ paddedA.set(bufA)
130
+ paddedB.set(bufB)
131
+
132
+ // If lengths differ the tokens cannot match, but we still compare in constant time.
133
+ return bufA.length === bufB.length && cryptoTimingSafeEqual(paddedA, paddedB)
129
134
  }
@@ -9,7 +9,8 @@ const SESSION_DEFAULTS: SessionConfig = {
9
9
  lifetime: 120,
10
10
  cookie: 'mantiq_session',
11
11
  path: '/',
12
- secure: false,
12
+ // Security: default to secure cookies (HTTPS-only). Override in dev config for HTTP.
13
+ secure: true,
13
14
  httpOnly: true,
14
15
  sameSite: 'Lax',
15
16
  }
@@ -162,6 +162,9 @@ export class SessionStore {
162
162
  await this.handler.destroy(this.id)
163
163
  }
164
164
  this.id = SessionStore.generateId()
165
+ // Security: regenerate the CSRF token alongside the session ID to prevent
166
+ // token fixation attacks (e.g. after login).
167
+ this.regenerateToken()
165
168
  }
166
169
 
167
170
  /**
@@ -182,7 +185,9 @@ export class SessionStore {
182
185
  // ── Static helpers ──────────────────────────────────────────────────────
183
186
 
184
187
  static generateId(): string {
185
- const bytes = new Uint8Array(20)
188
+ // Security: use 256 bits (32 bytes) of entropy per OWASP session ID guidelines.
189
+ // Produces a 64-char hex string.
190
+ const bytes = new Uint8Array(32)
186
191
  crypto.getRandomValues(bytes)
187
192
  let id = ''
188
193
  for (let i = 0; i < bytes.length; i++) {
@@ -8,11 +8,30 @@ import { parseCookies } from '../http/Cookie.ts'
8
8
  *
9
9
  * Core only provides the infrastructure — @mantiq/realtime registers
10
10
  * its handler via registerHandler(). Without it, all upgrades return 426.
11
+ *
12
+ * IMPORTANT: WebSocket upgrade requests bypass the HTTP middleware pipeline
13
+ * entirely. This means middleware like VerifyCsrfToken, StartSession, and
14
+ * CorsMiddleware do NOT run on WebSocket connections. Authentication must
15
+ * be handled in the onUpgrade hook (via the registered WebSocketHandler's
16
+ * `onUpgrade` method). Cookie decryption is handled manually below.
17
+ *
18
+ * If you need additional request validation on upgrade, register an
19
+ * `onUpgrade` callback in your WebSocketHandler implementation that
20
+ * performs the necessary checks (e.g., origin verification, rate limiting).
11
21
  */
12
22
  export class WebSocketKernel {
13
23
  private handler: WebSocketHandler | null = null
14
24
  private encrypter: AesEncrypter | null = null
15
25
 
26
+ /**
27
+ * Optional hooks that run during the upgrade request before the handler's
28
+ * onUpgrade. Use this to add origin checks, rate limiting, or other
29
+ * validation that would normally be done by HTTP middleware.
30
+ *
31
+ * Return a Response to reject the upgrade, or void/undefined to continue.
32
+ */
33
+ private upgradeHooks: Array<(request: MantiqRequest) => Promise<Response | void>> = []
34
+
16
35
  /**
17
36
  * Called by @mantiq/realtime to register its WebSocket handler.
18
37
  */
@@ -20,6 +39,18 @@ export class WebSocketKernel {
20
39
  this.handler = handler
21
40
  }
22
41
 
42
+ /**
43
+ * Register a hook that runs on every WebSocket upgrade request.
44
+ * Since WebSocket upgrades bypass the HTTP middleware pipeline,
45
+ * use this to add checks that middleware would normally handle
46
+ * (e.g., origin validation, rate limiting, auth).
47
+ *
48
+ * Return a Response to reject the upgrade, or void to continue.
49
+ */
50
+ onUpgrade(hook: (request: MantiqRequest) => Promise<Response | void>): void {
51
+ this.upgradeHooks.push(hook)
52
+ }
53
+
23
54
  /**
24
55
  * Inject the encrypter so WebSocket upgrades can decrypt cookies.
25
56
  * Called during CoreServiceProvider boot.
@@ -30,6 +61,9 @@ export class WebSocketKernel {
30
61
 
31
62
  /**
32
63
  * Called by HttpKernel when an upgrade request is detected.
64
+ *
65
+ * NOTE: The HTTP middleware pipeline does NOT run for WebSocket upgrades.
66
+ * Authentication is delegated to the handler's onUpgrade method.
33
67
  */
34
68
  async handleUpgrade(request: Request, server: any): Promise<Response> {
35
69
  if (!this.handler) {
@@ -47,6 +81,15 @@ export class WebSocketKernel {
47
81
  await this.decryptCookies(mantiqRequest)
48
82
  }
49
83
 
84
+ // Run onUpgrade hooks — these substitute for HTTP middleware that
85
+ // would normally run (auth, origin checks, rate limiting, etc.).
86
+ for (const hook of this.upgradeHooks) {
87
+ const rejection = await hook(mantiqRequest)
88
+ if (rejection instanceof Response) {
89
+ return rejection
90
+ }
91
+ }
92
+
50
93
  const context = await this.handler.onUpgrade(mantiqRequest)
51
94
 
52
95
  if (!context) {
@@ -65,15 +108,26 @@ export class WebSocketKernel {
65
108
  /**
66
109
  * Returns the Bun WebSocket handlers object for Bun.serve().
67
110
  * If no handler is registered, provides no-op stubs.
111
+ *
112
+ * Includes maxPayloadLength from the handler if available, which tells
113
+ * Bun to enforce a transport-level message size limit.
68
114
  */
69
115
  getBunHandlers(): object {
70
116
  const h = this.handler
71
- return {
117
+ const handlers: Record<string, any> = {
72
118
  open: (ws: any) => h?.open(ws),
73
119
  message: (ws: any, msg: any) => h?.message(ws, msg),
74
120
  close: (ws: any, code: number, reason: string) => h?.close(ws, code, reason),
75
121
  drain: (ws: any) => h?.drain(ws),
76
122
  }
123
+
124
+ // Pass maxPayloadLength to Bun's WebSocket config if the handler provides it.
125
+ // This enforces a transport-level limit on incoming message sizes.
126
+ if (h && typeof (h as any).getMaxPayloadLength === 'function') {
127
+ handlers.maxPayloadLength = (h as any).getMaxPayloadLength()
128
+ }
129
+
130
+ return handlers
77
131
  }
78
132
 
79
133
  // ── Private ───────────────────────────────────────────────────────────────