@stacksjs/bun-router 0.0.15 → 0.0.17
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/dist/auth.d.ts +22 -8
- package/dist/chunk-1ahs68ys.js +18 -0
- package/dist/{chunk-j0e7z7hd.js → chunk-cgptvjdf.js} +153 -75
- package/dist/chunk-g3ybefhg.js +1923 -0
- package/dist/cli.js +13 -11
- package/dist/container/container.d.ts +1 -0
- package/dist/container/contextual-binding.d.ts +0 -1
- package/dist/container/decorators.d.ts +2 -5
- package/dist/container/index.d.ts +16 -0
- package/dist/container/index.js +322 -0
- package/dist/file-serving/static-files.d.ts +16 -0
- package/dist/index.d.ts +17 -1
- package/dist/index.js +1086 -1270
- package/dist/middleware/cors.d.ts +9 -0
- package/dist/middleware/csrf.d.ts +7 -0
- package/dist/middleware/ddos_protection.d.ts +16 -1
- package/dist/middleware/rate_limit.d.ts +7 -1
- package/dist/middleware/request_tracer.d.ts +15 -9
- package/dist/middleware/response_cache.d.ts +2 -1
- package/dist/request/context.d.ts +12 -1
- package/dist/request/macros.d.ts +40 -2
- package/dist/response/macros.d.ts +3 -3
- package/dist/router/fluent-routing.d.ts +50 -13
- package/dist/router/handler-resolver.d.ts +8 -0
- package/dist/router/index.d.ts +2 -0
- package/dist/router/route-compiler.d.ts +8 -10
- package/dist/router/route-trie.d.ts +7 -1
- package/dist/router/router.d.ts +13 -2
- package/dist/router/validation-integration.d.ts +10 -4
- package/dist/types/middleware-types.d.ts +1 -1
- package/dist/types/route-inference.d.ts +9 -2
- package/dist/types.d.ts +62 -9
- package/dist/utils.d.ts +1 -1
- package/package.json +11 -1
- package/src/auth.ts +32 -12
- package/src/container/container.ts +58 -12
- package/src/container/contextual-binding.ts +4 -1
- package/src/container/decorators.ts +16 -7
- package/src/container/index.ts +49 -0
- package/src/errors/circuit-breaker.ts +3 -2
- package/src/errors/graceful-degradation.ts +2 -0
- package/src/file-serving/static-files.ts +144 -7
- package/src/index.ts +53 -1
- package/src/middleware/cors.ts +76 -8
- package/src/middleware/csrf.ts +80 -22
- package/src/middleware/ddos_protection.ts +27 -3
- package/src/middleware/input_validation.ts +10 -6
- package/src/middleware/performance_alerting.ts +2 -0
- package/src/middleware/performance_monitor.ts +2 -0
- package/src/middleware/rate_limit.ts +7 -1
- package/src/middleware/request_signing.ts +2 -0
- package/src/middleware/request_tracer.ts +34 -17
- package/src/middleware/response_cache.ts +48 -13
- package/src/middleware/session.ts +3 -3
- package/src/observability/metrics.ts +1 -1
- package/src/request/context.ts +50 -3
- package/src/request/enhanced-request.ts +20 -1
- package/src/request/macros.ts +183 -49
- package/src/response/macros.ts +3 -3
- package/src/router/file-based-routing.ts +17 -10
- package/src/router/fluent-routing.ts +117 -48
- package/src/router/group-organization.ts +17 -1
- package/src/router/handler-resolver.ts +36 -0
- package/src/router/http-methods.ts +33 -3
- package/src/router/index.ts +6 -0
- package/src/router/middleware.ts +3 -0
- package/src/router/optimized-route-matching.ts +58 -9
- package/src/router/route-compiler.ts +62 -73
- package/src/router/route-matching.ts +19 -0
- package/src/router/route-trie.ts +34 -48
- package/src/router/router.ts +72 -60
- package/src/router/server.ts +361 -139
- package/src/router/validation-integration.ts +11 -5
- package/src/testing/auth-testing.ts +6 -4
- package/src/types/middleware-types.ts +7 -4
- package/src/types/route-inference.ts +29 -14
- package/src/types.ts +63 -7
- package/src/url.ts +7 -2
- package/src/utils.ts +107 -39
- package/src/validation/validator.ts +7 -4
- package/src/websocket/clustering.ts +8 -1
- package/dist/router/fluent-router.d.ts +0 -315
- package/src/router/fluent-router.ts +0 -927
|
@@ -18,6 +18,12 @@ export interface StaticFileConfig {
|
|
|
18
18
|
compressionThreshold?: number
|
|
19
19
|
cacheControl?: string
|
|
20
20
|
dotfiles?: 'allow' | 'deny' | 'ignore'
|
|
21
|
+
/**
|
|
22
|
+
* Files larger than this (bytes) are streamed straight from disk via
|
|
23
|
+
* `Bun.file()` instead of being buffered into the in-memory cache.
|
|
24
|
+
* Streamed responses support HTTP Range requests (206). Default: 5 MiB.
|
|
25
|
+
*/
|
|
26
|
+
maxCacheFileSize?: number
|
|
21
27
|
}
|
|
22
28
|
|
|
23
29
|
export interface FileCache {
|
|
@@ -62,6 +68,7 @@ export class StaticFileServer {
|
|
|
62
68
|
compressionThreshold: config.compressionThreshold ?? 1024,
|
|
63
69
|
cacheControl: config.cacheControl ?? '',
|
|
64
70
|
dotfiles: config.dotfiles ?? 'ignore',
|
|
71
|
+
maxCacheFileSize: config.maxCacheFileSize ?? 5 * 1024 * 1024, // 5 MiB
|
|
65
72
|
...config,
|
|
66
73
|
}
|
|
67
74
|
}
|
|
@@ -71,10 +78,19 @@ export class StaticFileServer {
|
|
|
71
78
|
*/
|
|
72
79
|
async serve(request: Request): Promise<Response | null> {
|
|
73
80
|
const url = new URL(request.url)
|
|
74
|
-
const pathname = decodeURIComponent(url.pathname)
|
|
75
81
|
|
|
76
|
-
//
|
|
77
|
-
|
|
82
|
+
// Malformed percent-encoding must not crash the server with a 500
|
|
83
|
+
let pathname: string
|
|
84
|
+
try {
|
|
85
|
+
pathname = decodeURIComponent(url.pathname)
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return new Response('Bad Request', { status: 400 })
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Security: prevent directory traversal (the check runs after
|
|
92
|
+
// decoding, so encoded forms like %2e%2e are covered too)
|
|
93
|
+
if (pathname.includes('..') || pathname.includes('\0') || pathname.includes('\\')) {
|
|
78
94
|
return new Response('Forbidden', { status: 403 })
|
|
79
95
|
}
|
|
80
96
|
|
|
@@ -122,6 +138,12 @@ export class StaticFileServer {
|
|
|
122
138
|
return this.serveCachedFile(request, cached, requestPath, startTime)
|
|
123
139
|
}
|
|
124
140
|
|
|
141
|
+
// Large files are streamed straight from disk (with Range support)
|
|
142
|
+
// instead of buffered into the in-memory cache
|
|
143
|
+
if (size > this.config.maxCacheFileSize) {
|
|
144
|
+
return this.serveStreamedFile(request, filePath, requestPath, lastModified, startTime)
|
|
145
|
+
}
|
|
146
|
+
|
|
125
147
|
// Read and cache file
|
|
126
148
|
const content = await file.arrayBuffer()
|
|
127
149
|
const mimeType = file.type || this.getMimeType(filePath)
|
|
@@ -226,6 +248,75 @@ export class StaticFileServer {
|
|
|
226
248
|
return new Response(content, { headers })
|
|
227
249
|
}
|
|
228
250
|
|
|
251
|
+
/**
|
|
252
|
+
* Stream a file from disk without buffering it into memory.
|
|
253
|
+
* Supports single-range HTTP Range requests (RFC 9110 §14).
|
|
254
|
+
*/
|
|
255
|
+
private serveStreamedFile(
|
|
256
|
+
request: Request,
|
|
257
|
+
filePath: string,
|
|
258
|
+
requestPath: string,
|
|
259
|
+
lastModified: string,
|
|
260
|
+
startTime: number,
|
|
261
|
+
): Response {
|
|
262
|
+
const file = Bun.file(filePath)
|
|
263
|
+
const size = file.size
|
|
264
|
+
const mimeType = file.type || this.getMimeType(filePath)
|
|
265
|
+
|
|
266
|
+
const headers = new Headers()
|
|
267
|
+
headers.set('Content-Type', mimeType)
|
|
268
|
+
headers.set('Accept-Ranges', 'bytes')
|
|
269
|
+
|
|
270
|
+
if (this.config.lastModified) {
|
|
271
|
+
headers.set('Last-Modified', lastModified)
|
|
272
|
+
if (request.headers.get('if-modified-since') === lastModified) {
|
|
273
|
+
this.updateStats(requestPath, 0, startTime, true)
|
|
274
|
+
return new Response(null, { status: 304, headers })
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (this.config.cacheControl) {
|
|
279
|
+
headers.set('Cache-Control', this.config.cacheControl)
|
|
280
|
+
}
|
|
281
|
+
else {
|
|
282
|
+
headers.set('Cache-Control', this.config.immutable
|
|
283
|
+
? `public, max-age=${this.config.maxAge}, immutable`
|
|
284
|
+
: `public, max-age=${this.config.maxAge}`)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Single-range requests: "bytes=start-end", "bytes=start-", "bytes=-suffix"
|
|
288
|
+
const rangeHeader = request.headers.get('range')
|
|
289
|
+
const rangeMatch = rangeHeader?.match(/^bytes=(\d*)-(\d*)$/)
|
|
290
|
+
if (rangeMatch && (rangeMatch[1] !== '' || rangeMatch[2] !== '')) {
|
|
291
|
+
let start: number
|
|
292
|
+
let end: number
|
|
293
|
+
if (rangeMatch[1] === '') {
|
|
294
|
+
// Suffix range: last N bytes
|
|
295
|
+
const suffixLength = Number(rangeMatch[2])
|
|
296
|
+
start = Math.max(0, size - suffixLength)
|
|
297
|
+
end = size - 1
|
|
298
|
+
}
|
|
299
|
+
else {
|
|
300
|
+
start = Number(rangeMatch[1])
|
|
301
|
+
end = rangeMatch[2] === '' ? size - 1 : Math.min(Number(rangeMatch[2]), size - 1)
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (start > end || start >= size) {
|
|
305
|
+
headers.set('Content-Range', `bytes */${size}`)
|
|
306
|
+
return new Response(null, { status: 416, headers })
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
headers.set('Content-Range', `bytes ${start}-${end}/${size}`)
|
|
310
|
+
headers.set('Content-Length', String(end - start + 1))
|
|
311
|
+
this.updateStats(requestPath, end - start + 1, startTime, false)
|
|
312
|
+
return new Response(file.slice(start, end + 1), { status: 206, headers })
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
headers.set('Content-Length', String(size))
|
|
316
|
+
this.updateStats(requestPath, size, startTime, false)
|
|
317
|
+
return new Response(file, { headers })
|
|
318
|
+
}
|
|
319
|
+
|
|
229
320
|
/**
|
|
230
321
|
* Resolve file path, handling index files and extensions
|
|
231
322
|
*/
|
|
@@ -380,6 +471,15 @@ export class StaticFileServer {
|
|
|
380
471
|
this.compressionCache.clear()
|
|
381
472
|
}
|
|
382
473
|
|
|
474
|
+
/**
|
|
475
|
+
* Invalidate a single cached file (e.g. from a file watcher), leaving
|
|
476
|
+
* the rest of the cache warm.
|
|
477
|
+
*/
|
|
478
|
+
invalidateFile(filePath: string): void {
|
|
479
|
+
this.cache.delete(filePath)
|
|
480
|
+
this.compressionCache.delete(filePath)
|
|
481
|
+
}
|
|
482
|
+
|
|
383
483
|
/**
|
|
384
484
|
* Get cache size
|
|
385
485
|
*/
|
|
@@ -567,15 +667,52 @@ export const FileServingUtils = {
|
|
|
567
667
|
*/
|
|
568
668
|
createFileWatcher: async (server: StaticFileServer, watchPath: string): Promise<{ close: () => void }> => {
|
|
569
669
|
const fs = await import('node:fs')
|
|
570
|
-
const
|
|
571
|
-
|
|
670
|
+
const path = await import('node:path')
|
|
671
|
+
|
|
672
|
+
// Changes are debounced and invalidated per-file: a burst of writes
|
|
673
|
+
// (build output, git checkout) used to flush the entire cache once
|
|
674
|
+
// per event. Events without a filename still fall back to a full clear.
|
|
675
|
+
const pendingFiles = new Set<string>()
|
|
676
|
+
let fullClear = false
|
|
677
|
+
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
|
678
|
+
|
|
679
|
+
const flush = () => {
|
|
680
|
+
debounceTimer = null
|
|
681
|
+
if (fullClear) {
|
|
572
682
|
server.clearCache()
|
|
573
|
-
console.warn(`File changed: ${filename || 'unknown'}, cache cleared`)
|
|
574
683
|
}
|
|
684
|
+
else {
|
|
685
|
+
for (const file of pendingFiles) {
|
|
686
|
+
server.invalidateFile(file)
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
pendingFiles.clear()
|
|
690
|
+
fullClear = false
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
const watcher = fs.watch(watchPath, { recursive: true }, (eventType: string, filename: string | null) => {
|
|
694
|
+
if (eventType !== 'change' && eventType !== 'rename') {
|
|
695
|
+
return
|
|
696
|
+
}
|
|
697
|
+
if (filename) {
|
|
698
|
+
pendingFiles.add(path.join(watchPath, filename))
|
|
699
|
+
}
|
|
700
|
+
else {
|
|
701
|
+
fullClear = true
|
|
702
|
+
}
|
|
703
|
+
if (debounceTimer) {
|
|
704
|
+
clearTimeout(debounceTimer)
|
|
705
|
+
}
|
|
706
|
+
debounceTimer = setTimeout(flush, 100)
|
|
575
707
|
})
|
|
576
708
|
|
|
577
709
|
return {
|
|
578
|
-
close: () =>
|
|
710
|
+
close: () => {
|
|
711
|
+
if (debounceTimer) {
|
|
712
|
+
clearTimeout(debounceTimer)
|
|
713
|
+
}
|
|
714
|
+
watcher.close()
|
|
715
|
+
},
|
|
579
716
|
}
|
|
580
717
|
},
|
|
581
718
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,14 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @stacksjs/bun-router — a fast, type-safe router for Bun.
|
|
3
|
+
*
|
|
4
|
+
* Main entry point. The most commonly used exports:
|
|
5
|
+
* - `Router` — the primary router class (from `./router`)
|
|
6
|
+
* - `FluentRouter` / `router` — chainable routing API
|
|
7
|
+
* - `Auth`, `JWT`, `ApiKeyManager`, `OAuth2Helper` — auth helpers
|
|
8
|
+
* - `Container`, `createContainer` — dependency-injection container
|
|
9
|
+
* - `url()` / `registerNamedRoute()` — named-route URL generation
|
|
10
|
+
* - `Errors` — namespaced error/exception types
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
// Authentication (JWT, API keys, OAuth2)
|
|
1
14
|
export { default as Auth } from './auth'
|
|
2
15
|
export * from './auth'
|
|
16
|
+
|
|
17
|
+
// Runtime configuration
|
|
3
18
|
export * from './config'
|
|
19
|
+
|
|
20
|
+
// Dependency-injection container. Exported by name — the decorator module
|
|
21
|
+
// (`Get`, `Post`, `Controller`, …) would collide with router exports if
|
|
22
|
+
// star-exported; import those from `@stacksjs/bun-router/container/decorators`.
|
|
23
|
+
export {
|
|
24
|
+
BindingBuilder,
|
|
25
|
+
Container,
|
|
26
|
+
createContainer,
|
|
27
|
+
getContainer,
|
|
28
|
+
setContainer,
|
|
29
|
+
} from './container/container'
|
|
30
|
+
export type { Binding, BindingScope, ContainerOptions, ResolutionContext, Token } from './container/container'
|
|
31
|
+
export { ContextualContainer, EnvironmentManager } from './container/contextual-binding'
|
|
32
|
+
export { DefaultServiceProviderManager } from './container/service-provider'
|
|
33
|
+
|
|
34
|
+
// Error types and helpers (namespaced — many generic names)
|
|
4
35
|
export * as Errors from './errors'
|
|
36
|
+
|
|
37
|
+
// Built-in middleware (CORS, CSRF, sessions, rate limiting, …)
|
|
5
38
|
export * from './middleware'
|
|
6
|
-
|
|
39
|
+
|
|
40
|
+
// Request context (AsyncLocalStorage-backed current request; lazy — see enableRequestContext)
|
|
41
|
+
export { enableRequestContext, getCurrentRequest, request, runWithRequest, setCurrentRequest } from './request/context'
|
|
42
|
+
|
|
43
|
+
// Session stores and manager
|
|
7
44
|
export * from './session'
|
|
45
|
+
|
|
46
|
+
// Response factory helpers
|
|
8
47
|
export * from './response/response-factory'
|
|
48
|
+
|
|
49
|
+
// The Router class and routing features (fluent API, throttling, caching, …)
|
|
9
50
|
export * from './router'
|
|
10
51
|
export { MiddlewareGroupRegistry, middlewareGroups } from './router/middleware-groups'
|
|
52
|
+
|
|
53
|
+
// Testing utilities
|
|
11
54
|
export * from './testing'
|
|
55
|
+
|
|
56
|
+
// Public types
|
|
12
57
|
export * from './types'
|
|
58
|
+
|
|
59
|
+
// Validation (Laravel-style rules, custom rules, middleware)
|
|
60
|
+
export * from './validation/validator'
|
|
61
|
+
|
|
62
|
+
// Named-route URL generation
|
|
13
63
|
export { clearNamedRoutes, getNamedRoutePath, getNamedRoutes, registerNamedRoute, url, type UrlOptions } from './url'
|
|
64
|
+
|
|
65
|
+
// Path/template utilities
|
|
14
66
|
export * from './utils'
|
package/src/middleware/cors.ts
CHANGED
|
@@ -1,32 +1,100 @@
|
|
|
1
1
|
import type { EnhancedRequest, NextFunction } from '../types'
|
|
2
2
|
import { config } from '../config'
|
|
3
3
|
|
|
4
|
-
// Default CORS headers
|
|
4
|
+
// Default CORS headers.
|
|
5
|
+
//
|
|
6
|
+
// IMPORTANT: when credentials are NOT enabled it is safe to allow any origin
|
|
7
|
+
// with `*`. The default config below deliberately keeps credentials disabled.
|
|
8
|
+
// The `*` + `Allow-Credentials: true` combination is forbidden by the CORS
|
|
9
|
+
// spec and is dangerous (it lets any site read credentialed responses), so the
|
|
10
|
+
// logic in `getCorsHeaders` guarantees that combination can never be emitted.
|
|
5
11
|
const DEFAULT_CORS_HEADERS: Record<string, string> = {
|
|
6
12
|
'Access-Control-Allow-Origin': '*',
|
|
7
13
|
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, PATCH, OPTIONS',
|
|
8
14
|
'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Requested-With, Accept, Origin',
|
|
9
15
|
'Access-Control-Max-Age': '86400',
|
|
10
|
-
'Access-Control-Allow-Credentials': 'true',
|
|
11
16
|
}
|
|
12
17
|
|
|
13
18
|
export default class Cors {
|
|
14
|
-
|
|
15
|
-
|
|
19
|
+
/**
|
|
20
|
+
* Resolve the `Access-Control-Allow-Origin` value for a request.
|
|
21
|
+
*
|
|
22
|
+
* Supports a string origin (`'*'` or an explicit origin) or an array of
|
|
23
|
+
* allowed origins. For an allowlist, the request `Origin` is reflected back
|
|
24
|
+
* only when it is present in the list. When credentials are enabled a literal
|
|
25
|
+
* `*` is never returned (it would be both spec-violating and dangerous).
|
|
26
|
+
*/
|
|
27
|
+
private resolveAllowOrigin(
|
|
28
|
+
configuredOrigin: string | string[] | undefined,
|
|
29
|
+
credentials: boolean,
|
|
30
|
+
requestOrigin: string | null,
|
|
31
|
+
): { value: string | null, vary: boolean } {
|
|
32
|
+
// Explicit allowlist: only reflect the request origin when it matches.
|
|
33
|
+
if (Array.isArray(configuredOrigin)) {
|
|
34
|
+
if (requestOrigin && configuredOrigin.includes(requestOrigin)) {
|
|
35
|
+
return { value: requestOrigin, vary: true }
|
|
36
|
+
}
|
|
37
|
+
// Origin not allowed: do not emit an Allow-Origin header at all.
|
|
38
|
+
return { value: null, vary: true }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const origin = configuredOrigin || '*'
|
|
42
|
+
|
|
43
|
+
if (origin === '*') {
|
|
44
|
+
// The `*` wildcard cannot be combined with credentials. When credentials
|
|
45
|
+
// are enabled, reflect the specific request origin instead (which is the
|
|
46
|
+
// only spec-compliant way to support credentialed cross-origin requests).
|
|
47
|
+
if (credentials) {
|
|
48
|
+
if (requestOrigin) {
|
|
49
|
+
return { value: requestOrigin, vary: true }
|
|
50
|
+
}
|
|
51
|
+
// No request origin to reflect — omit the header rather than send `*`.
|
|
52
|
+
return { value: null, vary: true }
|
|
53
|
+
}
|
|
54
|
+
return { value: '*', vary: false }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// A single explicit origin was configured.
|
|
58
|
+
return { value: origin, vary: false }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
private getCorsHeaders(req: EnhancedRequest): Record<string, string> {
|
|
62
|
+
const corsConfig = config.server?.cors
|
|
63
|
+
const requestOrigin = req.headers.get('origin')
|
|
64
|
+
|
|
16
65
|
if (corsConfig && corsConfig.enabled) {
|
|
17
|
-
|
|
18
|
-
|
|
66
|
+
const credentials = Boolean(corsConfig.credentials)
|
|
67
|
+
const { value: allowOrigin, vary } = this.resolveAllowOrigin(corsConfig.origin, credentials, requestOrigin)
|
|
68
|
+
|
|
69
|
+
const headers: Record<string, string> = {
|
|
19
70
|
'Access-Control-Allow-Methods': Array.isArray(corsConfig.methods) ? corsConfig.methods.join(', ') : DEFAULT_CORS_HEADERS['Access-Control-Allow-Methods'],
|
|
20
71
|
'Access-Control-Allow-Headers': Array.isArray(corsConfig.allowedHeaders) ? corsConfig.allowedHeaders.join(', ') : DEFAULT_CORS_HEADERS['Access-Control-Allow-Headers'],
|
|
21
72
|
'Access-Control-Max-Age': String(corsConfig.maxAge || 86400),
|
|
22
|
-
'Access-Control-Allow-Credentials': String(corsConfig.credentials || false),
|
|
23
73
|
}
|
|
74
|
+
|
|
75
|
+
if (allowOrigin !== null) {
|
|
76
|
+
headers['Access-Control-Allow-Origin'] = allowOrigin
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Only advertise credentials when an origin is actually allowed. Never
|
|
80
|
+
// pair credentials with a `*` wildcard.
|
|
81
|
+
if (credentials && allowOrigin !== null && allowOrigin !== '*') {
|
|
82
|
+
headers['Access-Control-Allow-Credentials'] = 'true'
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (vary) {
|
|
86
|
+
headers.Vary = 'Origin'
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return headers
|
|
24
90
|
}
|
|
91
|
+
|
|
92
|
+
// No CORS config: fall back to the permissive (but credential-free) defaults.
|
|
25
93
|
return { ...DEFAULT_CORS_HEADERS }
|
|
26
94
|
}
|
|
27
95
|
|
|
28
96
|
async handle(req: EnhancedRequest, next: NextFunction): Promise<Response> {
|
|
29
|
-
const corsHeaders = this.getCorsHeaders()
|
|
97
|
+
const corsHeaders = this.getCorsHeaders(req)
|
|
30
98
|
|
|
31
99
|
// Handle preflight OPTIONS request
|
|
32
100
|
if (req.method === 'OPTIONS') {
|
package/src/middleware/csrf.ts
CHANGED
|
@@ -1,25 +1,27 @@
|
|
|
1
|
-
import type { EnhancedRequest, NextFunction } from '../types'
|
|
2
|
-
import { createHash, randomBytes } from 'node:crypto'
|
|
1
|
+
import type { EnhancedRequest, NextFunction, SecurityConfig } from '../types'
|
|
2
|
+
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
|
|
3
3
|
import { config } from '../config'
|
|
4
4
|
|
|
5
|
+
/** Maximum number of issued tokens kept for verification (oldest evicted first). */
|
|
6
|
+
const MAX_TOKENS = 10_000
|
|
7
|
+
|
|
8
|
+
/** Token lifetime in milliseconds. */
|
|
9
|
+
const TOKEN_TTL_MS = 2 * 60 * 60 * 1000 // 2 hours
|
|
10
|
+
|
|
5
11
|
export default class Csrf {
|
|
6
|
-
|
|
12
|
+
// Issued tokens with their issue timestamp. Bounded (LRU by insertion
|
|
13
|
+
// order) and TTL'd — the previous implementation grew without limit.
|
|
14
|
+
private static tokens = new Map<string, number>()
|
|
7
15
|
|
|
8
16
|
async handle(req: EnhancedRequest, next: NextFunction): Promise<Response> {
|
|
9
|
-
const csrfConfig = config.server?.security?.csrf
|
|
10
|
-
|
|
11
|
-
// Skip CSRF protection for safe methods
|
|
12
|
-
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) {
|
|
13
|
-
const response = await next()
|
|
14
|
-
return response || new Response('Not Found', { status: 404 })
|
|
15
|
-
}
|
|
17
|
+
const csrfConfig: Partial<SecurityConfig['csrf']> = config.server?.security?.csrf ?? {}
|
|
16
18
|
|
|
17
|
-
// Skip for methods that don't modify state
|
|
18
19
|
const method = req.method.toUpperCase()
|
|
19
20
|
const ignoredMethods = ['GET', 'HEAD', 'OPTIONS', ...(csrfConfig?.ignoreMethods || [])]
|
|
20
21
|
|
|
21
22
|
if (ignoredMethods.includes(method)) {
|
|
22
|
-
//
|
|
23
|
+
// Safe methods don't need verification, but the response carries a
|
|
24
|
+
// fresh token cookie so the client has something to submit later
|
|
23
25
|
const token = this.generateToken(csrfConfig?.secret || 'csrf-secret')
|
|
24
26
|
|
|
25
27
|
// Continue to next middleware
|
|
@@ -50,8 +52,8 @@ export default class Csrf {
|
|
|
50
52
|
const newHeaders = new Headers(response.headers)
|
|
51
53
|
newHeaders.append('Set-Cookie', cookieOptions.join('; '))
|
|
52
54
|
|
|
53
|
-
// Store token for verification
|
|
54
|
-
Csrf.
|
|
55
|
+
// Store token for verification (bounded + TTL)
|
|
56
|
+
Csrf.storeToken(token)
|
|
55
57
|
|
|
56
58
|
return new Response(response.body, {
|
|
57
59
|
status: response.status,
|
|
@@ -60,19 +62,33 @@ export default class Csrf {
|
|
|
60
62
|
})
|
|
61
63
|
}
|
|
62
64
|
|
|
65
|
+
// Bearer-authed requests can't be CSRF'd — the token doesn't
|
|
66
|
+
// ride on cookies, so a hostile origin can't trick a browser into
|
|
67
|
+
// sending it. Mirrors Laravel Sanctum / Django REST framework /
|
|
68
|
+
// express-csurf semantics. Filed against stacksjs/stacks#1922.
|
|
69
|
+
const authHeader = req.headers.get('authorization') ?? req.headers.get('Authorization')
|
|
70
|
+
if (authHeader && /^Bearer\s+\S/i.test(authHeader)) {
|
|
71
|
+
const response = await next()
|
|
72
|
+
return response || new Response('Not Found', { status: 404 })
|
|
73
|
+
}
|
|
74
|
+
|
|
63
75
|
// For unsafe methods, verify the CSRF token
|
|
64
76
|
// Check for token in headers or in the request body
|
|
65
|
-
const
|
|
77
|
+
const rawToken = req.headers.get('X-CSRF-TOKEN')
|
|
66
78
|
|| req.jsonBody?.csrf_token
|
|
67
79
|
|| req.jsonBody?._token
|
|
68
80
|
|| null
|
|
81
|
+
const token = typeof rawToken === 'string' ? rawToken : null
|
|
69
82
|
|
|
70
83
|
// Get token from cookies for comparison
|
|
71
84
|
const cookies = this.parseCookies(req)
|
|
72
85
|
const cookieToken = cookies[csrfConfig?.cookie?.name || 'csrf-token']
|
|
73
86
|
|
|
74
|
-
// Verify the token
|
|
75
|
-
|
|
87
|
+
// Verify the token: double-submit value must match the cookie
|
|
88
|
+
// (constant-time comparison) and must have been issued by us
|
|
89
|
+
if (!token || !cookieToken
|
|
90
|
+
|| !Csrf.safeCompare(token, cookieToken)
|
|
91
|
+
|| !Csrf.isTokenValid(token)) {
|
|
76
92
|
return new Response(
|
|
77
93
|
JSON.stringify({ error: 'CSRF token validation failed' }),
|
|
78
94
|
{
|
|
@@ -87,6 +103,42 @@ export default class Csrf {
|
|
|
87
103
|
return response || new Response('Not Found', { status: 404 })
|
|
88
104
|
}
|
|
89
105
|
|
|
106
|
+
/**
|
|
107
|
+
* Constant-time string comparison — a plain `!==` leaks how many leading
|
|
108
|
+
* characters of the token were correct through response timing.
|
|
109
|
+
*/
|
|
110
|
+
private static safeCompare(a: string, b: string): boolean {
|
|
111
|
+
const bufA = Buffer.from(a)
|
|
112
|
+
const bufB = Buffer.from(b)
|
|
113
|
+
if (bufA.length !== bufB.length) {
|
|
114
|
+
return false
|
|
115
|
+
}
|
|
116
|
+
return timingSafeEqual(bufA, bufB)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private static storeToken(token: string): void {
|
|
120
|
+
if (Csrf.tokens.size >= MAX_TOKENS) {
|
|
121
|
+
// Evict the oldest issued token (Map preserves insertion order)
|
|
122
|
+
const oldest = Csrf.tokens.keys().next().value
|
|
123
|
+
if (oldest !== undefined) {
|
|
124
|
+
Csrf.tokens.delete(oldest)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
Csrf.tokens.set(token, Date.now())
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
private static isTokenValid(token: string): boolean {
|
|
131
|
+
const issuedAt = Csrf.tokens.get(token)
|
|
132
|
+
if (issuedAt === undefined) {
|
|
133
|
+
return false
|
|
134
|
+
}
|
|
135
|
+
if (Date.now() - issuedAt > TOKEN_TTL_MS) {
|
|
136
|
+
Csrf.tokens.delete(token)
|
|
137
|
+
return false
|
|
138
|
+
}
|
|
139
|
+
return true
|
|
140
|
+
}
|
|
141
|
+
|
|
90
142
|
private generateToken(secret: string): string {
|
|
91
143
|
const randomString = randomBytes(16).toString('hex')
|
|
92
144
|
return createHash('sha256')
|
|
@@ -99,10 +151,16 @@ export default class Csrf {
|
|
|
99
151
|
if (!cookieHeader)
|
|
100
152
|
return {}
|
|
101
153
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
154
|
+
const cookies: Record<string, string> = {}
|
|
155
|
+
for (const cookie of cookieHeader.split(';')) {
|
|
156
|
+
const eqIndex = cookie.indexOf('=')
|
|
157
|
+
if (eqIndex === -1)
|
|
158
|
+
continue
|
|
159
|
+
const name = cookie.slice(0, eqIndex).trim()
|
|
160
|
+
if (!name)
|
|
161
|
+
continue
|
|
162
|
+
cookies[name] = cookie.slice(eqIndex + 1).trim()
|
|
163
|
+
}
|
|
164
|
+
return cookies
|
|
107
165
|
}
|
|
108
166
|
}
|
|
@@ -15,7 +15,7 @@ export interface DDoSProtectionOptions {
|
|
|
15
15
|
skipSuccessfulRequests?: boolean
|
|
16
16
|
skipFailedRequests?: boolean
|
|
17
17
|
keyGenerator?: (req: EnhancedRequest) => string
|
|
18
|
-
onLimitReached?: (req: EnhancedRequest, rateLimitInfo:
|
|
18
|
+
onLimitReached?: (req: EnhancedRequest, rateLimitInfo: DDoSRateLimitInfo) => Response | Promise<Response>
|
|
19
19
|
store?: 'memory' | 'redis'
|
|
20
20
|
redis?: {
|
|
21
21
|
url: string
|
|
@@ -31,6 +31,14 @@ interface RequestInfo {
|
|
|
31
31
|
blockExpires?: number
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Rate-limit details passed to `onLimitReached` when a client is blocked.
|
|
36
|
+
*/
|
|
37
|
+
export interface DDoSRateLimitInfo extends RequestInfo {
|
|
38
|
+
/** Seconds until the block lifts (mirrors the Retry-After header) */
|
|
39
|
+
retryAfter: number
|
|
40
|
+
}
|
|
41
|
+
|
|
34
42
|
export default class DDoSProtection {
|
|
35
43
|
private options: DDoSProtectionOptions
|
|
36
44
|
private requestStore: Map<string, RequestInfo> = new Map()
|
|
@@ -91,6 +99,8 @@ export default class DDoSProtection {
|
|
|
91
99
|
}
|
|
92
100
|
}
|
|
93
101
|
}, this.options.windowSize! / 2) // Cleanup every half window
|
|
102
|
+
// Background housekeeping must not keep the process alive
|
|
103
|
+
this.cleanupInterval.unref?.()
|
|
94
104
|
}
|
|
95
105
|
|
|
96
106
|
private isWhitelisted(ip: string): boolean {
|
|
@@ -105,6 +115,20 @@ export default class DDoSProtection {
|
|
|
105
115
|
let info = this.requestStore.get(key)
|
|
106
116
|
|
|
107
117
|
if (!info) {
|
|
118
|
+
// Hard cap on tracked clients: a spoofed-IP flood would otherwise
|
|
119
|
+
// grow the store faster than the interval cleanup can drain it.
|
|
120
|
+
// Evicting the oldest entries (Map insertion order) only resets
|
|
121
|
+
// counters for long-idle clients.
|
|
122
|
+
if (this.requestStore.size >= 100_000) {
|
|
123
|
+
let toEvict = Math.ceil(this.requestStore.size / 10)
|
|
124
|
+
for (const oldestKey of this.requestStore.keys()) {
|
|
125
|
+
this.requestStore.delete(oldestKey)
|
|
126
|
+
if (--toEvict <= 0) {
|
|
127
|
+
break
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
108
132
|
info = {
|
|
109
133
|
count: 0,
|
|
110
134
|
firstRequest: now,
|
|
@@ -149,7 +173,7 @@ export default class DDoSProtection {
|
|
|
149
173
|
info.blockExpires = now + this.options.blockDuration!
|
|
150
174
|
}
|
|
151
175
|
|
|
152
|
-
private createBlockedResponse(req: EnhancedRequest, info: RequestInfo): Response {
|
|
176
|
+
private createBlockedResponse(req: EnhancedRequest, info: RequestInfo): Response | Promise<Response> {
|
|
153
177
|
const retryAfter = Math.ceil((info.blockExpires! - Date.now()) / 1000)
|
|
154
178
|
|
|
155
179
|
const headers = {
|
|
@@ -160,7 +184,7 @@ export default class DDoSProtection {
|
|
|
160
184
|
}
|
|
161
185
|
|
|
162
186
|
if (this.options.onLimitReached) {
|
|
163
|
-
return this.options.onLimitReached(req, { ...info, retryAfter })
|
|
187
|
+
return this.options.onLimitReached(req, { ...info, retryAfter })
|
|
164
188
|
}
|
|
165
189
|
|
|
166
190
|
return new Response(JSON.stringify({
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import type { EnhancedRequest, NextFunction } from '../types'
|
|
2
2
|
import { config } from '../config'
|
|
3
3
|
|
|
4
|
+
// Hoisted format regexes — previously compiled on every validation call
|
|
5
|
+
const EMAIL_REGEX = /^[^\s@]+@[^\s@][^\s.@]*\.[^\s@]+$/
|
|
6
|
+
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
|
7
|
+
|
|
4
8
|
export interface ValidationRule {
|
|
5
9
|
type: 'string' | 'number' | 'boolean' | 'email' | 'url' | 'uuid' | 'date' | 'regex' | 'custom'
|
|
6
10
|
required?: boolean
|
|
@@ -148,8 +152,7 @@ export default class InputValidation {
|
|
|
148
152
|
break
|
|
149
153
|
|
|
150
154
|
case 'email': {
|
|
151
|
-
|
|
152
|
-
if (typeof value === 'string' && !emailRegex.test(value)) {
|
|
155
|
+
if (typeof value === 'string' && !EMAIL_REGEX.test(value)) {
|
|
153
156
|
errors.push({
|
|
154
157
|
field: fieldPath,
|
|
155
158
|
message: 'Must be a valid email address',
|
|
@@ -174,8 +177,7 @@ export default class InputValidation {
|
|
|
174
177
|
break
|
|
175
178
|
|
|
176
179
|
case 'uuid': {
|
|
177
|
-
|
|
178
|
-
if (typeof value === 'string' && !uuidRegex.test(value)) {
|
|
180
|
+
if (typeof value === 'string' && !UUID_REGEX.test(value)) {
|
|
179
181
|
errors.push({
|
|
180
182
|
field: fieldPath,
|
|
181
183
|
message: 'Must be a valid UUID',
|
|
@@ -233,8 +235,10 @@ export default class InputValidation {
|
|
|
233
235
|
})
|
|
234
236
|
}
|
|
235
237
|
|
|
236
|
-
// Pattern validation
|
|
237
|
-
|
|
238
|
+
// Pattern validation — skipped for type 'regex', which already
|
|
239
|
+
// validated the pattern above (running it twice produced duplicate
|
|
240
|
+
// errors for the same field)
|
|
241
|
+
if (rule.type !== 'regex' && rule.pattern && typeof value === 'string' && !rule.pattern.test(value)) {
|
|
238
242
|
errors.push({
|
|
239
243
|
field: fieldPath,
|
|
240
244
|
message: 'Does not match required pattern',
|
|
@@ -182,6 +182,8 @@ export default class PerformanceAlerting {
|
|
|
182
182
|
this.cleanupOldNotifications()
|
|
183
183
|
this.autoResolveAlerts()
|
|
184
184
|
}, 10000) // Check every 10 seconds
|
|
185
|
+
// Background checks must not keep the process alive
|
|
186
|
+
this.checkInterval.unref?.()
|
|
185
187
|
}
|
|
186
188
|
|
|
187
189
|
private evaluateRules(): void {
|
|
@@ -269,6 +269,8 @@ export default class PerformanceMonitor implements Middleware {
|
|
|
269
269
|
this.cleanupInterval = setInterval(() => {
|
|
270
270
|
this.cleanupOldMetrics()
|
|
271
271
|
}, 60000) // Cleanup every minute
|
|
272
|
+
// Background housekeeping must not keep the process alive
|
|
273
|
+
this.cleanupInterval.unref?.()
|
|
272
274
|
}
|
|
273
275
|
|
|
274
276
|
private cleanupOldMetrics(): void {
|