0http-bun 1.2.2 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +502 -9
- package/common.d.ts +16 -0
- package/lib/middleware/README.md +124 -23
- package/lib/middleware/body-parser.js +410 -263
- package/lib/middleware/cors.js +57 -57
- package/lib/middleware/index.d.ts +27 -24
- package/lib/middleware/index.js +4 -0
- package/lib/middleware/jwt-auth.js +129 -37
- package/lib/middleware/rate-limit.js +77 -27
- package/lib/next.js +8 -1
- package/lib/router/sequential.js +41 -10
- package/package.json +7 -7
package/lib/middleware/README.md
CHANGED
|
@@ -166,12 +166,50 @@ router.use(createBodyParser(bodyParserOptions))
|
|
|
166
166
|
|
|
167
167
|
**Supported Content Types:**
|
|
168
168
|
|
|
169
|
-
- `application/json` - Parsed as JSON
|
|
169
|
+
- `application/json` - Parsed as JSON (strict matching; `application/xml` and other `application/*` types are no longer routed to the JSON parser)
|
|
170
170
|
- `application/x-www-form-urlencoded` - Parsed as form data
|
|
171
171
|
- `multipart/form-data` - Parsed as FormData
|
|
172
172
|
- `text/*` - Parsed as plain text
|
|
173
173
|
- `application/octet-stream` - Parsed as ArrayBuffer
|
|
174
174
|
|
|
175
|
+
**Security Features:**
|
|
176
|
+
|
|
177
|
+
- **JSON nesting depth limit** (max 100) with string-aware scanning — brace characters inside JSON strings do not count toward the depth limit
|
|
178
|
+
- **Prototype pollution protection** — multipart body and files objects use `Object.create(null)`, and dangerous property names (`__proto__`, `constructor`, `prototype`, etc.) are blocked
|
|
179
|
+
- **Multipart filename sanitization** — strips `..`, path separators (`/`, `\`), null bytes, and leading dots. The original filename is preserved in `file.originalName`
|
|
180
|
+
- **Size limit validation** — `parseLimit()` throws `TypeError` for unexpected types (e.g., `null`, `false`, objects) instead of silently defaulting to 1MB
|
|
181
|
+
- **Custom `jsonParser` safety** — when a custom `jsonParser` function is provided, size limits are enforced before the parser is called
|
|
182
|
+
- **Empty body handling** — empty or whitespace-only JSON bodies set `req.body` to `undefined` instead of silently returning `{}`
|
|
183
|
+
- **Raw body via Symbol** — raw body text is stored via a Symbol (`RAW_BODY_SYMBOL`) instead of a public string property, preventing accidental serialization/logging
|
|
184
|
+
|
|
185
|
+
**Accessing Raw Body:**
|
|
186
|
+
|
|
187
|
+
```javascript
|
|
188
|
+
import {RAW_BODY_SYMBOL} from '0http-bun/lib/middleware/body-parser'
|
|
189
|
+
|
|
190
|
+
router.post('/webhook', (req) => {
|
|
191
|
+
const rawBody = req[RAW_BODY_SYMBOL] // Symbol.for('0http.rawBody')
|
|
192
|
+
// Use rawBody for signature verification, etc.
|
|
193
|
+
})
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
**Verify Function:**
|
|
197
|
+
|
|
198
|
+
When using the `verify` option with `createBodyParser`, the raw body is available via the same symbol:
|
|
199
|
+
|
|
200
|
+
```javascript
|
|
201
|
+
const bodyParser = createBodyParser({
|
|
202
|
+
verify: (req, rawBody) => {
|
|
203
|
+
// rawBody is the raw string before parsing
|
|
204
|
+
const signature = req.headers.get('x-signature')
|
|
205
|
+
if (!verifySignature(rawBody, signature)) {
|
|
206
|
+
throw new Error('Invalid signature')
|
|
207
|
+
}
|
|
208
|
+
},
|
|
209
|
+
// Note: any JSON parsing behavior (such as deferNext) is handled internally when verify is set.
|
|
210
|
+
})
|
|
211
|
+
```
|
|
212
|
+
|
|
175
213
|
### CORS
|
|
176
214
|
|
|
177
215
|
Cross-Origin Resource Sharing middleware with flexible configuration.
|
|
@@ -199,10 +237,13 @@ router.use(
|
|
|
199
237
|
)
|
|
200
238
|
|
|
201
239
|
// Dynamic origin validation
|
|
240
|
+
// NOTE: null and missing origins are automatically rejected before calling the
|
|
241
|
+
// validator function, preventing bypass via sandboxed iframes.
|
|
202
242
|
router.use(
|
|
203
243
|
createCORS({
|
|
204
244
|
origin: (origin, req) => {
|
|
205
245
|
// Custom logic to validate origin
|
|
246
|
+
// `origin` is guaranteed to be a non-null, non-'null' string here
|
|
206
247
|
return (
|
|
207
248
|
origin?.endsWith('.mycompany.com') || origin === 'http://localhost:3000'
|
|
208
249
|
)
|
|
@@ -211,6 +252,13 @@ router.use(
|
|
|
211
252
|
)
|
|
212
253
|
```
|
|
213
254
|
|
|
255
|
+
**Security behavior:**
|
|
256
|
+
|
|
257
|
+
- When an origin is **not allowed**, CORS headers (methods, allowed headers, credentials, exposed headers) are **not set** on the response. Only `Vary: Origin` is added.
|
|
258
|
+
- `null` origins (from sandboxed iframes, `file://` URLs, etc.) are **rejected** for array and function origin configurations.
|
|
259
|
+
- `Vary: Origin` is set for all non-wildcard origin configurations to prevent CDN cache poisoning.
|
|
260
|
+
- Wildcard (`*`) origins with `credentials: true` are blocked (CORS spec requirement).
|
|
261
|
+
|
|
214
262
|
**TypeScript Usage:**
|
|
215
263
|
|
|
216
264
|
```typescript
|
|
@@ -329,7 +377,9 @@ router.use(
|
|
|
329
377
|
jwksUri: process.env.JWKS_URI,
|
|
330
378
|
|
|
331
379
|
// JWT verification options
|
|
332
|
-
|
|
380
|
+
// IMPORTANT: Do NOT mix symmetric (HS*) and asymmetric (RS*/ES*/PS*) algorithms.
|
|
381
|
+
// Use HS256 with static secrets, or RS256/ES256 with JWKS URIs.
|
|
382
|
+
algorithms: ['HS256'],
|
|
333
383
|
issuer: 'your-app',
|
|
334
384
|
audience: 'your-users',
|
|
335
385
|
clockTolerance: 10, // Clock skew tolerance (seconds)
|
|
@@ -341,14 +391,13 @@ router.use(
|
|
|
341
391
|
// Try multiple sources
|
|
342
392
|
return (
|
|
343
393
|
req.headers.get('x-auth-token') ||
|
|
344
|
-
req.headers.get('authorization')?.replace('Bearer ', '')
|
|
345
|
-
new URL(req.url).searchParams.get('token')
|
|
394
|
+
req.headers.get('authorization')?.replace('Bearer ', '')
|
|
346
395
|
)
|
|
347
396
|
},
|
|
348
397
|
|
|
349
398
|
// Alternative token sources
|
|
350
399
|
tokenHeader: 'x-custom-token', // Custom header name
|
|
351
|
-
tokenQuery: 'access_token', // Query parameter name
|
|
400
|
+
tokenQuery: 'access_token', // Query parameter name (see security note below)
|
|
352
401
|
|
|
353
402
|
// Error handling
|
|
354
403
|
onError: (err, req) => {
|
|
@@ -377,14 +426,22 @@ router.use(
|
|
|
377
426
|
},
|
|
378
427
|
|
|
379
428
|
// Optional authentication (proceed even without token)
|
|
429
|
+
// When optional: true, invalid tokens set req.ctx.authError and req.ctx.authAttempted = true
|
|
380
430
|
optional: false,
|
|
381
431
|
|
|
382
|
-
// Exclude certain paths
|
|
432
|
+
// Exclude certain paths (uses exact match or path boundary, NOT prefix matching)
|
|
433
|
+
// e.g., '/health' excludes '/health' and '/health/...' but NOT '/healthcheck'
|
|
383
434
|
excludePaths: ['/health', '/metrics', '/api/public'],
|
|
435
|
+
|
|
436
|
+
// Token type validation (validates the JWT 'typ' header claim)
|
|
437
|
+
// Case-insensitive comparison. Rejects tokens with missing or incorrect type.
|
|
438
|
+
requiredTokenType: 'JWT', // or 'at+jwt' for access tokens, etc.
|
|
384
439
|
}),
|
|
385
440
|
)
|
|
386
441
|
```
|
|
387
442
|
|
|
443
|
+
> **Security Note on `tokenQuery`:** Passing JWTs via query parameters exposes tokens in server logs, browser history, and HTTP `Referer` headers. Prefer header-based token extraction in production.
|
|
444
|
+
|
|
388
445
|
#### API Key Authentication
|
|
389
446
|
|
|
390
447
|
The JWT middleware also supports API key authentication as an alternative or fallback:
|
|
@@ -399,6 +456,14 @@ router.use(
|
|
|
399
456
|
}),
|
|
400
457
|
)
|
|
401
458
|
|
|
459
|
+
// Access the raw API key (req.apiKey is masked for security)
|
|
460
|
+
import {API_KEY_SYMBOL} from '0http-bun/lib/middleware/jwt-auth'
|
|
461
|
+
|
|
462
|
+
router.get('/api/data', (req) => {
|
|
463
|
+
console.log(req.apiKey) // 'xxxx****xxxx' (masked, safe for logging)
|
|
464
|
+
console.log(req[API_KEY_SYMBOL]) // Raw API key via Symbol.for('0http.apiKey')
|
|
465
|
+
})
|
|
466
|
+
|
|
402
467
|
// API key with custom validation
|
|
403
468
|
router.use(
|
|
404
469
|
'/api/*',
|
|
@@ -457,15 +522,22 @@ router.use('/api/protected/*', createJWTAuth(jwtConfig))
|
|
|
457
522
|
```typescript
|
|
458
523
|
// Access decoded token in route handlers
|
|
459
524
|
router.get('/api/profile', (req) => {
|
|
460
|
-
//
|
|
525
|
+
// Decoded JWT payload
|
|
461
526
|
console.log(req.user) // Decoded JWT payload
|
|
462
527
|
console.log(req.ctx.user) // Same as req.user
|
|
463
|
-
|
|
528
|
+
|
|
529
|
+
// JWT header and payload (raw token is NOT included for security)
|
|
530
|
+
console.log(req.jwt) // { payload, header }
|
|
464
531
|
console.log(req.ctx.jwt) // Same as req.jwt
|
|
465
532
|
|
|
466
533
|
// API key authentication data (if used)
|
|
467
|
-
console.log(req.apiKey) // API key
|
|
468
|
-
console.log(req.ctx.apiKey) // Same as req.apiKey
|
|
534
|
+
console.log(req.apiKey) // Masked API key (xxxx****xxxx)
|
|
535
|
+
console.log(req.ctx.apiKey) // Same as req.apiKey (masked)
|
|
536
|
+
|
|
537
|
+
// Optional mode: check if auth was attempted but failed
|
|
538
|
+
if (req.ctx.authAttempted && req.ctx.authError) {
|
|
539
|
+
console.log('Auth failed:', req.ctx.authError)
|
|
540
|
+
}
|
|
469
541
|
|
|
470
542
|
return Response.json({
|
|
471
543
|
user: req.user,
|
|
@@ -618,9 +690,7 @@ const prometheus = createPrometheusIntegration({
|
|
|
618
690
|
#### Custom Business Metrics
|
|
619
691
|
|
|
620
692
|
```javascript
|
|
621
|
-
const {
|
|
622
|
-
createPrometheusIntegration,
|
|
623
|
-
} = require('0http-bun/lib/middleware')
|
|
693
|
+
const {createPrometheusIntegration} = require('0http-bun/lib/middleware')
|
|
624
694
|
|
|
625
695
|
// Get the prometheus client from the integration
|
|
626
696
|
const prometheus = createPrometheusIntegration()
|
|
@@ -783,8 +853,16 @@ router.use(
|
|
|
783
853
|
windowMs: 60 * 1000, // 1 minute
|
|
784
854
|
max: 20, // Max requests
|
|
785
855
|
keyGenerator: (req) => {
|
|
786
|
-
// Custom key generation
|
|
787
|
-
|
|
856
|
+
// Custom key generation
|
|
857
|
+
// Default uses: req.ip || req.remoteAddress || req.socket?.remoteAddress || 'unknown'
|
|
858
|
+
// NOTE: Proxy headers are NOT trusted by default. If behind a
|
|
859
|
+
// reverse proxy, you MUST provide a custom keyGenerator:
|
|
860
|
+
return (
|
|
861
|
+
req.headers.get('x-user-id') ||
|
|
862
|
+
req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
|
|
863
|
+
req.ip ||
|
|
864
|
+
'unknown'
|
|
865
|
+
)
|
|
788
866
|
},
|
|
789
867
|
skip: (req) => {
|
|
790
868
|
// Skip rate limiting for certain requests
|
|
@@ -802,6 +880,12 @@ router.use(
|
|
|
802
880
|
)
|
|
803
881
|
},
|
|
804
882
|
standardHeaders: true, // Send X-RateLimit-* headers
|
|
883
|
+
// standardHeaders options:
|
|
884
|
+
// true - full headers (X-RateLimit-Limit, Remaining, Reset, Used) — default
|
|
885
|
+
// false - no rate limit headers
|
|
886
|
+
// 'minimal' - only Retry-After on 429 responses (hides exact config/counters)
|
|
887
|
+
// excludePaths uses exact match or boundary matching (NOT prefix)
|
|
888
|
+
// e.g., '/health' matches '/health' and '/health/...' but NOT '/healthcheck'
|
|
805
889
|
excludePaths: ['/health', '/metrics'],
|
|
806
890
|
}),
|
|
807
891
|
)
|
|
@@ -811,6 +895,8 @@ router.use(
|
|
|
811
895
|
createRateLimit({
|
|
812
896
|
store: new MemoryStore(), // Built-in memory store
|
|
813
897
|
// Or implement custom store with increment() method
|
|
898
|
+
// Note: The store is always the constructor-configured instance.
|
|
899
|
+
// It cannot be overridden at runtime via the request object.
|
|
814
900
|
}),
|
|
815
901
|
)
|
|
816
902
|
```
|
|
@@ -825,10 +911,12 @@ const rateLimitOptions: RateLimitOptions = {
|
|
|
825
911
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
|
826
912
|
max: 100,
|
|
827
913
|
keyGenerator: (req) => {
|
|
914
|
+
// Default: req.ip || req.remoteAddress || req.socket?.remoteAddress || 'unknown'
|
|
915
|
+
// Custom: read from proxy-set header if behind a reverse proxy
|
|
828
916
|
return (
|
|
829
|
-
req.headers.get('x-
|
|
830
|
-
req.
|
|
831
|
-
'
|
|
917
|
+
req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
|
|
918
|
+
req.ip ||
|
|
919
|
+
'unknown'
|
|
832
920
|
)
|
|
833
921
|
},
|
|
834
922
|
standardHeaders: true,
|
|
@@ -839,6 +927,8 @@ router.use(createRateLimit(rateLimitOptions))
|
|
|
839
927
|
|
|
840
928
|
// Custom store implementation
|
|
841
929
|
class CustomStore implements RateLimitStore {
|
|
930
|
+
// Note: For MemoryStore, increment is synchronous to prevent TOCTOU races.
|
|
931
|
+
// Custom stores may be async if using external backends (e.g., Redis).
|
|
842
932
|
async increment(
|
|
843
933
|
key: string,
|
|
844
934
|
windowMs: number,
|
|
@@ -861,11 +951,15 @@ router.use(
|
|
|
861
951
|
createSlidingWindowRateLimit({
|
|
862
952
|
windowMs: 60 * 1000, // 1 minute sliding window
|
|
863
953
|
max: 10, // Max 10 requests per minute
|
|
864
|
-
|
|
954
|
+
maxKeys: 10000, // Maximum tracked keys (default: 10000) — prevents unbounded memory growth
|
|
955
|
+
keyGenerator: (req) =>
|
|
956
|
+
req.ip || req.remoteAddress || req.socket?.remoteAddress || 'unknown',
|
|
865
957
|
}),
|
|
866
958
|
)
|
|
867
959
|
```
|
|
868
960
|
|
|
961
|
+
> **Memory safety:** The sliding window rate limiter enforces a `maxKeys` limit (default: 10,000). When the limit is exceeded, the oldest entry is evicted. A periodic cleanup interval runs at most every 60 seconds (or `windowMs`, whichever is shorter) and uses `unref()` so it doesn't prevent process exit.
|
|
962
|
+
|
|
869
963
|
**TypeScript Usage:**
|
|
870
964
|
|
|
871
965
|
```typescript
|
|
@@ -875,7 +969,8 @@ import type {RateLimitOptions} from '0http-bun/lib/middleware'
|
|
|
875
969
|
const slidingOptions: RateLimitOptions = {
|
|
876
970
|
windowMs: 60 * 1000, // 1 minute
|
|
877
971
|
max: 10, // 10 requests max
|
|
878
|
-
|
|
972
|
+
maxKeys: 10000, // Max tracked keys (default: 10000)
|
|
973
|
+
keyGenerator: (req) => req.user?.id || req.ip || 'unknown',
|
|
879
974
|
handler: (req, hits, max, resetTime) => {
|
|
880
975
|
return Response.json(
|
|
881
976
|
{
|
|
@@ -920,7 +1015,8 @@ router.use(
|
|
|
920
1015
|
createSlidingWindowRateLimit({
|
|
921
1016
|
windowMs: 3600 * 1000, // 1 hour
|
|
922
1017
|
max: 3, // 3 accounts per IP per hour
|
|
923
|
-
keyGenerator: (req) =>
|
|
1018
|
+
keyGenerator: (req) =>
|
|
1019
|
+
req.ip || req.remoteAddress || req.socket?.remoteAddress || 'unknown',
|
|
924
1020
|
}),
|
|
925
1021
|
)
|
|
926
1022
|
|
|
@@ -937,8 +1033,9 @@ router.use(
|
|
|
937
1033
|
|
|
938
1034
|
**Performance Considerations:**
|
|
939
1035
|
|
|
940
|
-
- **Memory Usage**: Higher than fixed window (stores timestamp arrays)
|
|
1036
|
+
- **Memory Usage**: Higher than fixed window (stores timestamp arrays), bounded by `maxKeys` (default: 10,000)
|
|
941
1037
|
- **Time Complexity**: O(n) per request where n = requests in window
|
|
1038
|
+
- **Cleanup**: Automatic periodic cleanup runs at most every 60 seconds; interval uses `unref()` to not prevent process exit
|
|
942
1039
|
- **Best For**: Critical APIs, financial transactions, user-facing features
|
|
943
1040
|
- **Use Fixed Window For**: High-volume APIs where approximate limiting is acceptable
|
|
944
1041
|
|
|
@@ -966,6 +1063,8 @@ Both rate limiters send the following headers when `standardHeaders: true`:
|
|
|
966
1063
|
- `X-RateLimit-Reset` - Reset time (Unix timestamp)
|
|
967
1064
|
- `X-RateLimit-Used` - Used requests
|
|
968
1065
|
|
|
1066
|
+
When `standardHeaders: 'minimal'`, only `Retry-After` is sent on 429 responses. This prevents disclosing exact rate limit configuration and usage counters to clients.
|
|
1067
|
+
|
|
969
1068
|
**Error Handling:**
|
|
970
1069
|
|
|
971
1070
|
Rate limiting middleware allows errors to bubble up as proper HTTP 500 responses. If your `keyGenerator` function or custom `store.increment()` method throws an error, it will not be caught and masked - the error will propagate up the middleware chain for proper error handling.
|
|
@@ -1102,10 +1201,12 @@ router.use(
|
|
|
1102
1201
|
router.use(createLogger({format: 'combined'}))
|
|
1103
1202
|
|
|
1104
1203
|
// 3. Rate limiting (protect against abuse)
|
|
1204
|
+
// NOTE: Default key generator uses req.ip — provide keyGenerator if behind a proxy
|
|
1105
1205
|
router.use(
|
|
1106
1206
|
createRateLimit({
|
|
1107
1207
|
windowMs: 15 * 60 * 1000,
|
|
1108
1208
|
max: 1000,
|
|
1209
|
+
// keyGenerator: (req) => req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || req.ip || 'unknown',
|
|
1109
1210
|
}),
|
|
1110
1211
|
)
|
|
1111
1212
|
|
|
@@ -1113,11 +1214,11 @@ router.use(
|
|
|
1113
1214
|
router.use(createBodyParser({limit: '10mb'}))
|
|
1114
1215
|
|
|
1115
1216
|
// 5. Authentication (protect API routes)
|
|
1217
|
+
// Default algorithms: ['HS256'] — specify ['RS256'] for asymmetric keys
|
|
1116
1218
|
router.use(
|
|
1117
1219
|
'/api/*',
|
|
1118
1220
|
createJWTAuth({
|
|
1119
1221
|
secret: process.env.JWT_SECRET,
|
|
1120
|
-
skip: (req) => req.url.includes('/api/public/'),
|
|
1121
1222
|
}),
|
|
1122
1223
|
)
|
|
1123
1224
|
|