@mantiq/core 0.5.22 → 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 +1 -1
- package/src/application/Application.ts +132 -10
- package/src/cache/FileCacheStore.ts +73 -9
- package/src/cache/MemoryCacheStore.ts +8 -0
- package/src/contracts/Request.ts +3 -2
- package/src/contracts/Router.ts +2 -0
- package/src/encryption/errors.ts +5 -2
- package/src/errors/ConfigKeyNotFoundError.ts +6 -1
- package/src/errors/ContainerResolutionError.ts +3 -0
- package/src/errors/ErrorCodes.ts +27 -0
- package/src/errors/ForbiddenError.ts +2 -1
- package/src/errors/HttpError.ts +4 -1
- package/src/errors/MantiqError.ts +12 -1
- package/src/errors/NotFoundError.ts +2 -1
- package/src/errors/TokenMismatchError.ts +2 -1
- package/src/errors/TooManyRequestsError.ts +2 -1
- package/src/errors/UnauthorizedError.ts +2 -1
- package/src/errors/ValidationError.ts +2 -1
- package/src/exceptions/Handler.ts +10 -2
- package/src/helpers/signedUrl.ts +26 -0
- package/src/helpers/url.ts +31 -0
- package/src/http/Kernel.ts +56 -0
- package/src/http/Request.ts +9 -5
- package/src/index.ts +9 -0
- package/src/middleware/Cors.ts +21 -11
- package/src/middleware/EncryptCookies.ts +15 -5
- package/src/middleware/RouteModelBinding.ts +43 -0
- package/src/middleware/StartSession.ts +10 -0
- package/src/middleware/TimeoutMiddleware.ts +47 -0
- package/src/middleware/VerifyCsrfToken.ts +12 -7
- package/src/providers/CoreServiceProvider.ts +26 -0
- package/src/routing/Route.ts +11 -0
- package/src/routing/Router.ts +32 -1
- package/src/session/SessionManager.ts +2 -1
- package/src/session/Store.ts +6 -1
- package/src/url/UrlSigner.ts +131 -0
- package/src/websocket/WebSocketKernel.ts +58 -2
package/src/http/Kernel.ts
CHANGED
|
@@ -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.
|
|
@@ -202,11 +216,53 @@ export class HttpKernel {
|
|
|
202
216
|
|
|
203
217
|
// ── Private ───────────────────────────────────────────────────────────────
|
|
204
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
|
+
|
|
205
257
|
/**
|
|
206
258
|
* Call the route action (controller method or closure).
|
|
207
259
|
* Converts the return value to a Response.
|
|
208
260
|
*/
|
|
209
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
|
+
|
|
210
266
|
const action = match.action
|
|
211
267
|
|
|
212
268
|
let result: any
|
package/src/http/Request.ts
CHANGED
|
@@ -58,15 +58,15 @@ export class MantiqRequest implements MantiqRequestContract {
|
|
|
58
58
|
return this.parsedQuery[key] ?? defaultValue ?? (undefined as any)
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
async input
|
|
62
|
-
async input(key: string, defaultValue?: any): Promise<
|
|
63
|
-
async input(key?: string, defaultValue?: any): Promise<
|
|
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> {
|
|
64
64
|
if (!this.parsedBody) {
|
|
65
65
|
await this.parseBody()
|
|
66
66
|
}
|
|
67
67
|
const merged = { ...this.query(), ...this.parsedBody }
|
|
68
|
-
if (key === undefined) return merged
|
|
69
|
-
return merged[key] ?? defaultValue
|
|
68
|
+
if (key === undefined) return merged as T
|
|
69
|
+
return (merged[key] ?? defaultValue) as T
|
|
70
70
|
}
|
|
71
71
|
|
|
72
72
|
async only(...keys: string[]): Promise<Record<string, any>> {
|
|
@@ -211,6 +211,10 @@ export class MantiqRequest implements MantiqRequestContract {
|
|
|
211
211
|
this.routeParams = params
|
|
212
212
|
}
|
|
213
213
|
|
|
214
|
+
setRouteParam(key: string, value: any): void {
|
|
215
|
+
this.routeParams[key] = value
|
|
216
|
+
}
|
|
217
|
+
|
|
214
218
|
// ── Session ──────────────────────────────────────────────────────────────
|
|
215
219
|
|
|
216
220
|
session(): SessionStore {
|
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'
|
|
@@ -57,6 +60,8 @@ 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'
|
|
59
62
|
export { SecureHeaders } from './middleware/SecureHeaders.ts'
|
|
63
|
+
export { TimeoutMiddleware } from './middleware/TimeoutMiddleware.ts'
|
|
64
|
+
export { RouteModelBinding } from './middleware/RouteModelBinding.ts'
|
|
60
65
|
export { Enum } from './support/Enum.ts'
|
|
61
66
|
export { WebSocketKernel } from './websocket/WebSocketKernel.ts'
|
|
62
67
|
export { DefaultExceptionHandler } from './exceptions/Handler.ts'
|
|
@@ -64,6 +69,9 @@ export { CoreServiceProvider } from './providers/CoreServiceProvider.ts'
|
|
|
64
69
|
export { Discoverer } from './discovery/Discoverer.ts'
|
|
65
70
|
export type { DiscoveryManifest } from './discovery/Discoverer.ts'
|
|
66
71
|
|
|
72
|
+
// ── URL Signing ──────────────────────────────────────────────────────────────
|
|
73
|
+
export { UrlSigner } from './url/UrlSigner.ts'
|
|
74
|
+
|
|
67
75
|
// ── Encryption ────────────────────────────────────────────────────────────────
|
|
68
76
|
export { AesEncrypter } from './encryption/Encrypter.ts'
|
|
69
77
|
|
|
@@ -103,4 +111,5 @@ export { hash, hashCheck } from './helpers/hash.ts'
|
|
|
103
111
|
export { cache } from './helpers/cache.ts'
|
|
104
112
|
export { session } from './helpers/session.ts'
|
|
105
113
|
export { dd, dump } from './helpers/dd.ts'
|
|
114
|
+
export { signedUrl, hasValidSignature } from './helpers/signedUrl.ts'
|
|
106
115
|
export { base_path, app_path, config_path, database_path, storage_path, public_path, resource_path } from './helpers/paths.ts'
|
package/src/middleware/Cors.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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)
|
|
@@ -47,11 +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 — expired key, tampered, or wrong format
|
|
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.
|
|
51
53
|
if (process.env.APP_DEBUG === 'true') {
|
|
52
|
-
console.warn(`[Mantiq] Failed to decrypt cookie "${name}" —
|
|
54
|
+
console.warn(`[Mantiq] Failed to decrypt cookie "${name}" — discarding value`)
|
|
53
55
|
}
|
|
54
|
-
decrypted[name] =
|
|
56
|
+
decrypted[name] = ''
|
|
55
57
|
}
|
|
56
58
|
}
|
|
57
59
|
|
|
@@ -86,10 +88,18 @@ export class EncryptCookies implements Middleware {
|
|
|
86
88
|
try {
|
|
87
89
|
const encrypted = await this.encrypter.encrypt(value)
|
|
88
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
|
+
|
|
89
98
|
headers.append('Set-Cookie', newSetCookie)
|
|
90
99
|
} catch {
|
|
91
|
-
//
|
|
92
|
-
|
|
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.`)
|
|
93
103
|
}
|
|
94
104
|
}
|
|
95
105
|
|
|
@@ -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
|
+
}
|
|
@@ -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
|
}
|
|
@@ -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
|
+
}
|
|
@@ -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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
}
|
|
@@ -11,6 +11,7 @@ import { EncryptCookies } from '../middleware/EncryptCookies.ts'
|
|
|
11
11
|
import { VerifyCsrfToken } from '../middleware/VerifyCsrfToken.ts'
|
|
12
12
|
import { ThrottleRequests } from '../rateLimit/ThrottleRequests.ts'
|
|
13
13
|
import { SecureHeaders } from '../middleware/SecureHeaders.ts'
|
|
14
|
+
import { TimeoutMiddleware } from '../middleware/TimeoutMiddleware.ts'
|
|
14
15
|
import { ROUTER } from '../helpers/route.ts'
|
|
15
16
|
import { ENCRYPTER } from '../helpers/encrypt.ts'
|
|
16
17
|
import { AesEncrypter } from '../encryption/Encrypter.ts'
|
|
@@ -82,6 +83,7 @@ export class CoreServiceProvider extends ServiceProvider {
|
|
|
82
83
|
// Rate limiting — zero-config, uses shared in-memory store
|
|
83
84
|
this.app.singleton(ThrottleRequests, () => new ThrottleRequests())
|
|
84
85
|
this.app.singleton(SecureHeaders, () => new SecureHeaders())
|
|
86
|
+
this.app.bind(TimeoutMiddleware, () => new TimeoutMiddleware())
|
|
85
87
|
|
|
86
88
|
// HTTP kernel — singleton, depends on Router + ExceptionHandler + WsKernel
|
|
87
89
|
this.app.singleton(HttpKernel, (c) => {
|
|
@@ -113,6 +115,7 @@ export class CoreServiceProvider extends ServiceProvider {
|
|
|
113
115
|
kernel.registerMiddleware('session', StartSession)
|
|
114
116
|
kernel.registerMiddleware('csrf', VerifyCsrfToken)
|
|
115
117
|
kernel.registerMiddleware('secure-headers', SecureHeaders)
|
|
118
|
+
kernel.registerMiddleware('timeout', TimeoutMiddleware)
|
|
116
119
|
|
|
117
120
|
// Register middleware groups from config
|
|
118
121
|
const configRepo = this.app.make(ConfigRepository)
|
|
@@ -125,6 +128,29 @@ export class CoreServiceProvider extends ServiceProvider {
|
|
|
125
128
|
kernel.registerMiddlewareGroup(name, middleware)
|
|
126
129
|
}
|
|
127
130
|
|
|
131
|
+
// ── Boot-time convention validation ────────────────────────────────────
|
|
132
|
+
// Validate that all aliases referenced by middleware groups are registered
|
|
133
|
+
for (const [group, aliases] of Object.entries(middlewareGroups)) {
|
|
134
|
+
for (const alias of aliases) {
|
|
135
|
+
const name = alias.split(':')[0]!
|
|
136
|
+
if (!kernel.hasMiddleware(name)) {
|
|
137
|
+
throw new Error(
|
|
138
|
+
`Middleware group '${group}' references unknown alias '${name}'.\n` +
|
|
139
|
+
`Registered aliases: ${kernel.getRegisteredAliases().join(', ')}`,
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Validate APP_KEY when encrypt.cookies middleware is active
|
|
146
|
+
const needsKey = Object.values(middlewareGroups).flat().includes('encrypt.cookies')
|
|
147
|
+
if (needsKey && !appKey) {
|
|
148
|
+
throw new Error(
|
|
149
|
+
'APP_KEY is required when encrypt.cookies middleware is active.\n' +
|
|
150
|
+
'Generate one with: bun mantiq key:generate',
|
|
151
|
+
)
|
|
152
|
+
}
|
|
153
|
+
|
|
128
154
|
// Legacy: if app.middleware is set, apply as global middleware (backward compat)
|
|
129
155
|
const globalMiddleware = configRepo.get('app.middleware', []) as string[]
|
|
130
156
|
if (globalMiddleware.length > 0) {
|
package/src/routing/Route.ts
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
import type { HttpMethod, RouteAction, RouterRoute } from '../contracts/Router.ts'
|
|
2
2
|
|
|
3
|
+
export interface RouteBinding {
|
|
4
|
+
model: any
|
|
5
|
+
key: string
|
|
6
|
+
}
|
|
7
|
+
|
|
3
8
|
export class Route implements RouterRoute {
|
|
4
9
|
public routeName?: string
|
|
5
10
|
public middlewareList: string[] = []
|
|
6
11
|
public wheres: Record<string, RegExp> = {}
|
|
12
|
+
public bindings = new Map<string, RouteBinding>()
|
|
7
13
|
|
|
8
14
|
constructor(
|
|
9
15
|
public readonly methods: HttpMethod[],
|
|
@@ -37,4 +43,9 @@ export class Route implements RouterRoute {
|
|
|
37
43
|
whereUuid(param: string): this {
|
|
38
44
|
return this.where(param, /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i)
|
|
39
45
|
}
|
|
46
|
+
|
|
47
|
+
bind(param: string, model: any, key = 'id'): this {
|
|
48
|
+
this.bindings.set(param, { model, key })
|
|
49
|
+
return this
|
|
50
|
+
}
|
|
40
51
|
}
|
package/src/routing/Router.ts
CHANGED
|
@@ -160,12 +160,43 @@ export class RouterImpl implements RouterContract {
|
|
|
160
160
|
const result = RouteMatcher.match(route, pathname)
|
|
161
161
|
if (result) {
|
|
162
162
|
RouterImpl._dispatcher?.emit(new RouteMatched(route.routeName, route.action, request))
|
|
163
|
-
|
|
163
|
+
|
|
164
|
+
// Merge route-level bindings with router-level bindings (route-level takes precedence)
|
|
165
|
+
const bindings = new Map<string, { model: any; key: string }>()
|
|
166
|
+
|
|
167
|
+
// Add router-level model bindings (model class → where('id', value).first())
|
|
168
|
+
for (const [param, ModelClass] of this.modelBindings) {
|
|
169
|
+
if (result.params[param] !== undefined) {
|
|
170
|
+
bindings.set(param, { model: ModelClass, key: 'id' })
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Add router-level custom bindings (resolver function)
|
|
175
|
+
for (const [param, resolver] of this.customBindings) {
|
|
176
|
+
if (result.params[param] !== undefined) {
|
|
177
|
+
bindings.set(param, { model: resolver, key: '__custom__' })
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Route-level bindings override router-level
|
|
182
|
+
for (const [param, binding] of route.bindings) {
|
|
183
|
+
if (result.params[param] !== undefined) {
|
|
184
|
+
bindings.set(param, binding)
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const match: RouteMatch = {
|
|
164
189
|
action: route.action,
|
|
165
190
|
params: result.params,
|
|
166
191
|
middleware: route.middlewareList,
|
|
167
192
|
routeName: route.routeName,
|
|
168
193
|
}
|
|
194
|
+
|
|
195
|
+
if (bindings.size > 0) {
|
|
196
|
+
match.bindings = bindings
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return match
|
|
169
200
|
}
|
|
170
201
|
}
|
|
171
202
|
|
|
@@ -9,7 +9,8 @@ const SESSION_DEFAULTS: SessionConfig = {
|
|
|
9
9
|
lifetime: 120,
|
|
10
10
|
cookie: 'mantiq_session',
|
|
11
11
|
path: '/',
|
|
12
|
-
|
|
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
|
}
|
package/src/session/Store.ts
CHANGED
|
@@ -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
|
-
|
|
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++) {
|