@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.
- package/package.json +1 -1
- package/src/application/Application.ts +65 -7
- package/src/cache/FileCacheStore.ts +42 -3
- package/src/cache/MemoryCacheStore.ts +14 -2
- package/src/contracts/Request.ts +10 -2
- package/src/contracts/Router.ts +2 -0
- package/src/discovery/Discoverer.ts +1 -3
- 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/DevErrorPage.ts +27 -5
- package/src/exceptions/Handler.ts +1 -0
- package/src/helpers/signedUrl.ts +26 -0
- package/src/helpers/url.ts +31 -0
- package/src/http/Kernel.ts +91 -2
- package/src/http/Request.ts +60 -11
- package/src/http/Response.ts +54 -1
- package/src/http/UploadedFile.ts +24 -1
- package/src/index.ts +11 -0
- package/src/middleware/Cors.ts +9 -1
- package/src/middleware/EncryptCookies.ts +14 -2
- package/src/middleware/RouteModelBinding.ts +43 -0
- package/src/middleware/SecureHeaders.ts +72 -0
- package/src/middleware/TimeoutMiddleware.ts +47 -0
- package/src/providers/CoreServiceProvider.ts +33 -0
- package/src/routing/Route.ts +11 -0
- package/src/routing/Router.ts +32 -1
- package/src/session/Store.ts +2 -1
- package/src/support/Enum.ts +96 -0
- package/src/url/UrlSigner.ts +131 -0
- package/src/websocket/WebSocketKernel.ts +45 -0
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.
|
|
@@ -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
|
|
104
|
-
|
|
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
|
package/src/http/Request.ts
CHANGED
|
@@ -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
|
|
59
|
-
async input(key: string, defaultValue?: any): Promise<
|
|
60
|
-
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> {
|
|
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
|
-
//
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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 —
|
|
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
|
}
|
package/src/http/Response.ts
CHANGED
|
@@ -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':
|
|
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 })
|
package/src/http/UploadedFile.ts
CHANGED
|
@@ -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'
|
package/src/middleware/Cors.ts
CHANGED
|
@@ -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
|
-
|
|
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 —
|
|
51
|
-
|
|
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
|
+
}
|