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