@mantiq/core 0.5.21 → 0.5.23

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 (40) hide show
  1. package/package.json +1 -1
  2. package/src/application/Application.ts +65 -7
  3. package/src/cache/FileCacheStore.ts +42 -3
  4. package/src/cache/MemoryCacheStore.ts +14 -2
  5. package/src/contracts/Request.ts +10 -2
  6. package/src/contracts/Router.ts +2 -0
  7. package/src/discovery/Discoverer.ts +1 -3
  8. package/src/encryption/errors.ts +5 -2
  9. package/src/errors/ConfigKeyNotFoundError.ts +6 -1
  10. package/src/errors/ContainerResolutionError.ts +3 -0
  11. package/src/errors/ErrorCodes.ts +27 -0
  12. package/src/errors/ForbiddenError.ts +2 -1
  13. package/src/errors/HttpError.ts +4 -1
  14. package/src/errors/MantiqError.ts +12 -1
  15. package/src/errors/NotFoundError.ts +2 -1
  16. package/src/errors/TokenMismatchError.ts +2 -1
  17. package/src/errors/TooManyRequestsError.ts +2 -1
  18. package/src/errors/UnauthorizedError.ts +2 -1
  19. package/src/errors/ValidationError.ts +2 -1
  20. package/src/exceptions/DevErrorPage.ts +27 -5
  21. package/src/exceptions/Handler.ts +1 -0
  22. package/src/helpers/signedUrl.ts +26 -0
  23. package/src/helpers/url.ts +31 -0
  24. package/src/http/Kernel.ts +91 -2
  25. package/src/http/Request.ts +60 -11
  26. package/src/http/Response.ts +54 -1
  27. package/src/http/UploadedFile.ts +24 -1
  28. package/src/index.ts +11 -0
  29. package/src/middleware/Cors.ts +9 -1
  30. package/src/middleware/EncryptCookies.ts +14 -2
  31. package/src/middleware/RouteModelBinding.ts +43 -0
  32. package/src/middleware/SecureHeaders.ts +72 -0
  33. package/src/middleware/TimeoutMiddleware.ts +47 -0
  34. package/src/providers/CoreServiceProvider.ts +33 -0
  35. package/src/routing/Route.ts +11 -0
  36. package/src/routing/Router.ts +32 -1
  37. package/src/session/Store.ts +2 -1
  38. package/src/support/Enum.ts +96 -0
  39. package/src/url/UrlSigner.ts +131 -0
  40. package/src/websocket/WebSocketKernel.ts +45 -0
@@ -56,6 +56,20 @@ export class HttpKernel {
56
56
  this.globalMiddleware = middleware
57
57
  }
58
58
 
59
+ /**
60
+ * Check whether a middleware alias has been registered.
61
+ */
62
+ hasMiddleware(alias: string): boolean {
63
+ return alias in this.middlewareAliases
64
+ }
65
+
66
+ /**
67
+ * Return all registered middleware alias names.
68
+ */
69
+ getRegisteredAliases(): string[] {
70
+ return Object.keys(this.middlewareAliases)
71
+ }
72
+
59
73
  /**
60
74
  * Middleware registered by packages that run before the app's global middleware.
61
75
  * Separate from globalMiddleware so setGlobalMiddleware() doesn't overwrite them.
@@ -99,9 +113,42 @@ export class HttpKernel {
99
113
 
100
114
  const request = MantiqRequest.fromBun(bunRequest)
101
115
 
116
+ // Pass the direct connection IP from Bun's server
117
+ const socketAddr = server.requestIP(bunRequest)
118
+ if (socketAddr) {
119
+ request.setConnectionIp(socketAddr.address)
120
+ }
121
+
122
+ // Configure trusted proxies and body size limit from config
123
+ let maxBodySize = 10 * 1024 * 1024 // default 10MB
124
+ try {
125
+ const config = this.container.make(ConfigRepository)
126
+ const proxies = config.get<string[]>('app.trustedProxies', [])
127
+ if (proxies && proxies.length > 0) {
128
+ request.setTrustedProxies(proxies)
129
+ }
130
+ maxBodySize = config.get<number>('app.maxBodySize', maxBodySize)
131
+ } catch {
132
+ // ConfigRepository may not be bound yet — leave defaults
133
+ }
134
+
135
+ // Reject oversized request bodies before reading them (413 Payload Too Large)
136
+ const contentLength = Number(bunRequest.headers.get('content-length'))
137
+ if (contentLength > maxBodySize) {
138
+ return new Response('Payload Too Large', { status: 413 })
139
+ }
140
+
102
141
  try {
103
- // Combine prepend + global + append middleware (deduplicated, preserving order)
104
- const allMiddleware = [...new Set([...this.prependMiddleware, ...this.globalMiddleware, ...this.appendMiddleware])]
142
+ // Combine prepend + global + append middleware
143
+ // Deduplicate exact duplicates but keep parameterized variants (auth:admin vs auth:user)
144
+ const seen = new Set<string>()
145
+ const allMiddleware: string[] = []
146
+ for (const mw of [...this.prependMiddleware, ...this.globalMiddleware, ...this.appendMiddleware]) {
147
+ if (!seen.has(mw)) {
148
+ seen.add(mw)
149
+ allMiddleware.push(mw)
150
+ }
151
+ }
105
152
  const globalClasses = this.resolveMiddlewareList(allMiddleware)
106
153
 
107
154
  const response = await new Pipeline(this.container)
@@ -169,11 +216,53 @@ export class HttpKernel {
169
216
 
170
217
  // ── Private ───────────────────────────────────────────────────────────────
171
218
 
219
+ /**
220
+ * Resolve route model bindings.
221
+ * Replaces raw parameter values (e.g. '42') with model instances.
222
+ * Returns a 404 response if any bound model is not found.
223
+ */
224
+ private async resolveBindings(
225
+ match: RouteMatch,
226
+ request: MantiqRequestContract,
227
+ ): Promise<Response | null> {
228
+ if (!match.bindings || match.bindings.size === 0) return null
229
+
230
+ for (const [param, { model, key }] of match.bindings) {
231
+ const value = request.param(param)
232
+ if (value === undefined) continue
233
+
234
+ let instance: any
235
+
236
+ if (key === '__custom__') {
237
+ // Custom resolver function (from router.bind())
238
+ instance = await model(String(value))
239
+ } else {
240
+ // Model class with .where().first()
241
+ instance = await model.where(key, value).first()
242
+ }
243
+
244
+ if (!instance) {
245
+ return new Response(
246
+ JSON.stringify({ error: `${param} not found.` }),
247
+ { status: 404, headers: { 'Content-Type': 'application/json' } },
248
+ )
249
+ }
250
+
251
+ request.setRouteParam(param, instance)
252
+ }
253
+
254
+ return null
255
+ }
256
+
172
257
  /**
173
258
  * Call the route action (controller method or closure).
174
259
  * Converts the return value to a Response.
175
260
  */
176
261
  private async callAction(match: RouteMatch, request: MantiqRequestContract): Promise<Response> {
262
+ // Resolve model bindings before calling the action
263
+ const bindingError = await this.resolveBindings(match, request)
264
+ if (bindingError) return bindingError
265
+
177
266
  const action = match.action
178
267
 
179
268
  let result: any
@@ -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,
@@ -55,15 +58,15 @@ export class MantiqRequest implements MantiqRequestContract {
55
58
  return this.parsedQuery[key] ?? defaultValue ?? (undefined as any)
56
59
  }
57
60
 
58
- async input(): Promise<Record<string, any>>
59
- async input(key: string, defaultValue?: any): Promise<any>
60
- async input(key?: string, defaultValue?: any): Promise<any> {
61
+ async input<T = Record<string, any>>(): Promise<T>
62
+ async input<T = any>(key: string, defaultValue?: any): Promise<T>
63
+ async input<T = any>(key?: string, defaultValue?: any): Promise<T> {
61
64
  if (!this.parsedBody) {
62
65
  await this.parseBody()
63
66
  }
64
67
  const merged = { ...this.query(), ...this.parsedBody }
65
- if (key === undefined) return merged
66
- return merged[key] ?? defaultValue
68
+ if (key === undefined) return merged as T
69
+ return (merged[key] ?? defaultValue) as T
67
70
  }
68
71
 
69
72
  async only(...keys: string[]): Promise<Record<string, any>> {
@@ -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 {
@@ -176,6 +211,10 @@ export class MantiqRequest implements MantiqRequestContract {
176
211
  this.routeParams = params
177
212
  }
178
213
 
214
+ setRouteParam(key: string, value: any): void {
215
+ this.routeParams[key] = value
216
+ }
217
+
179
218
  // ── Session ──────────────────────────────────────────────────────────────
180
219
 
181
220
  session(): SessionStore {
@@ -213,6 +252,12 @@ export class MantiqRequest implements MantiqRequestContract {
213
252
  this.authenticatedUser = user
214
253
  }
215
254
 
255
+ // ── FormData ────────────────────────────────────────────────────────────
256
+
257
+ async formData(): Promise<FormData> {
258
+ return this.bunRequest.clone().formData() as Promise<FormData>
259
+ }
260
+
216
261
  // ── Raw ──────────────────────────────────────────────────────────────────
217
262
 
218
263
  raw(): Request {
@@ -250,8 +295,12 @@ export class MantiqRequest implements MantiqRequestContract {
250
295
  }
251
296
  }
252
297
  }
253
- } catch {
254
- // Body parsing failed — leave parsedBody as empty object
298
+ } catch (error) {
299
+ // Body parsing failed — store the error for inspection
300
+ this._bodyError = error instanceof Error ? error : new Error(String(error))
301
+ if (typeof process !== 'undefined' && process.env?.APP_DEBUG === 'true') {
302
+ console.warn('[Mantiq] Body parse error:', this._bodyError.message)
303
+ }
255
304
  }
256
305
  }
257
306
  }
@@ -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
@@ -24,6 +24,8 @@ export { Event, Listener } from './contracts/EventDispatcher.ts'
24
24
  export type { WebSocketHandler, WebSocketContext } from './websocket/WebSocketContext.ts'
25
25
 
26
26
  // ── Errors ────────────────────────────────────────────────────────────────────
27
+ export { ErrorCodes } from './errors/ErrorCodes.ts'
28
+ export type { ErrorCode } from './errors/ErrorCodes.ts'
27
29
  export { MantiqError } from './errors/MantiqError.ts'
28
30
  export { HttpError } from './errors/HttpError.ts'
29
31
  export { NotFoundError } from './errors/NotFoundError.ts'
@@ -46,6 +48,7 @@ export { UploadedFile } from './http/UploadedFile.ts'
46
48
  export { HttpKernel } from './http/Kernel.ts'
47
49
  export { RouterImpl } from './routing/Router.ts'
48
50
  export { Route } from './routing/Route.ts'
51
+ export type { RouteBinding } from './routing/Route.ts'
49
52
  export { RouteMatched } from './routing/events.ts'
50
53
  export { Pipeline } from './middleware/Pipeline.ts'
51
54
  export { CorsMiddleware } from './middleware/Cors.ts'
@@ -56,12 +59,19 @@ export { VerifyCsrfToken } from './middleware/VerifyCsrfToken.ts'
56
59
  export { RateLimiter, MemoryStore } from './rateLimit/RateLimiter.ts'
57
60
  export type { RateLimitConfig, RateLimitStore, LimiterResolver } from './rateLimit/RateLimiter.ts'
58
61
  export { ThrottleRequests, getDefaultRateLimiter, setDefaultRateLimiter } from './rateLimit/ThrottleRequests.ts'
62
+ export { SecureHeaders } from './middleware/SecureHeaders.ts'
63
+ export { TimeoutMiddleware } from './middleware/TimeoutMiddleware.ts'
64
+ export { RouteModelBinding } from './middleware/RouteModelBinding.ts'
65
+ export { Enum } from './support/Enum.ts'
59
66
  export { WebSocketKernel } from './websocket/WebSocketKernel.ts'
60
67
  export { DefaultExceptionHandler } from './exceptions/Handler.ts'
61
68
  export { CoreServiceProvider } from './providers/CoreServiceProvider.ts'
62
69
  export { Discoverer } from './discovery/Discoverer.ts'
63
70
  export type { DiscoveryManifest } from './discovery/Discoverer.ts'
64
71
 
72
+ // ── URL Signing ──────────────────────────────────────────────────────────────
73
+ export { UrlSigner } from './url/UrlSigner.ts'
74
+
65
75
  // ── Encryption ────────────────────────────────────────────────────────────────
66
76
  export { AesEncrypter } from './encryption/Encrypter.ts'
67
77
 
@@ -101,4 +111,5 @@ export { hash, hashCheck } from './helpers/hash.ts'
101
111
  export { cache } from './helpers/cache.ts'
102
112
  export { session } from './helpers/session.ts'
103
113
  export { dd, dump } from './helpers/dd.ts'
114
+ export { signedUrl, hasValidSignature } from './helpers/signedUrl.ts'
104
115
  export { base_path, app_path, config_path, database_path, storage_path, public_path, resource_path } from './helpers/paths.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,8 +47,13 @@ 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.)
51
- decrypted[name] = value
50
+ // Can't decrypt — expired key, tampered, or wrong format.
51
+ // Discard the value instead of passing through the encrypted blob,
52
+ // which could cause unexpected behavior downstream.
53
+ if (process.env.APP_DEBUG === 'true') {
54
+ console.warn(`[Mantiq] Failed to decrypt cookie "${name}" — discarding value`)
55
+ }
56
+ decrypted[name] = ''
52
57
  }
53
58
  }
54
59
 
@@ -83,6 +88,13 @@ export class EncryptCookies implements Middleware {
83
88
  try {
84
89
  const encrypted = await this.encrypter.encrypt(value)
85
90
  const newSetCookie = `${encodeURIComponent(name)}=${encodeURIComponent(encrypted)}; ${parts.join('; ')}`
91
+
92
+ if (newSetCookie.length > 4096) {
93
+ console.warn(
94
+ `[Mantiq] Cookie "${name}" exceeds 4KB (${newSetCookie.length} bytes) — browsers may silently ignore it.`,
95
+ )
96
+ }
97
+
86
98
  headers.append('Set-Cookie', newSetCookie)
87
99
  } catch {
88
100
  // If encryption fails, send unencrypted (shouldn't happen with valid key)
@@ -0,0 +1,43 @@
1
+ import type { Middleware, NextFunction } from '../contracts/Middleware.ts'
2
+ import type { MantiqRequest } from '../contracts/Request.ts'
3
+
4
+ /**
5
+ * Middleware that resolves route parameters to model instances.
6
+ *
7
+ * Register bindings before adding to the middleware stack:
8
+ *
9
+ * @example
10
+ * const binding = new RouteModelBinding()
11
+ * binding.bind('user', User) // looks up User.where('id', value).first()
12
+ * binding.bind('post', Post, 'slug') // looks up Post.where('slug', value).first()
13
+ *
14
+ * kernel.registerMiddleware('bindings', RouteModelBinding)
15
+ */
16
+ export class RouteModelBinding implements Middleware {
17
+ private bindings = new Map<string, { model: any; key: string }>()
18
+
19
+ bind(param: string, model: any, key = 'id'): void {
20
+ this.bindings.set(param, { model, key })
21
+ }
22
+
23
+ async handle(request: MantiqRequest, next: NextFunction): Promise<Response> {
24
+ const params = request.params()
25
+
26
+ for (const [param, { model, key }] of this.bindings) {
27
+ const value = params[param]
28
+ if (value === undefined) continue
29
+
30
+ const instance = await model.where(key, value).first()
31
+ if (!instance) {
32
+ return new Response(
33
+ JSON.stringify({ error: `${param} not found.` }),
34
+ { status: 404, headers: { 'Content-Type': 'application/json' } },
35
+ )
36
+ }
37
+
38
+ request.setRouteParam(param, instance)
39
+ }
40
+
41
+ return next()
42
+ }
43
+ }
@@ -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
+ }
@@ -0,0 +1,47 @@
1
+ import type { Middleware, NextFunction } from '../contracts/Middleware.ts'
2
+ import type { MantiqRequest } from '../contracts/Request.ts'
3
+
4
+ /**
5
+ * Request timeout middleware.
6
+ *
7
+ * Aborts requests that exceed a configurable time limit, returning a 408
8
+ * status. Default timeout is 30 seconds.
9
+ *
10
+ * Usage with route alias:
11
+ * route.get('/heavy', handler).middleware('timeout') // 30s default
12
+ * route.get('/heavy', handler).middleware('timeout:60') // 60s
13
+ */
14
+ export class TimeoutMiddleware implements Middleware {
15
+ private timeout = 30_000 // default 30s
16
+
17
+ setParameters(params: string[]): void {
18
+ if (params[0]) this.timeout = Number(params[0]) * 1000
19
+ }
20
+
21
+ async handle(request: MantiqRequest, next: NextFunction): Promise<Response> {
22
+ const controller = new AbortController()
23
+ const timer = setTimeout(() => controller.abort(), this.timeout)
24
+
25
+ try {
26
+ const response = await Promise.race([
27
+ next(),
28
+ new Promise<never>((_, reject) => {
29
+ controller.signal.addEventListener('abort', () =>
30
+ reject(new Error(`Request timed out after ${this.timeout}ms`)),
31
+ )
32
+ }),
33
+ ])
34
+ clearTimeout(timer)
35
+ return response
36
+ } catch (err: any) {
37
+ clearTimeout(timer)
38
+ if (err.message?.includes('timed out')) {
39
+ return new Response(JSON.stringify({ error: 'Request Timeout' }), {
40
+ status: 408,
41
+ headers: { 'Content-Type': 'application/json' },
42
+ })
43
+ }
44
+ throw err
45
+ }
46
+ }
47
+ }