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.
@@ -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
- algorithms: ['HS256', 'RS256'],
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
- // Multiple ways to access user data
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
- console.log(req.jwt) // Full JWT info (payload, header, token)
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 value
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 (default: IP address)
787
- return req.headers.get('x-user-id') || req.headers.get('x-forwarded-for')
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-user-id') ||
830
- req.headers.get('x-forwarded-for') ||
831
- 'anonymous'
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
- keyGenerator: (req) => req.headers.get('x-forwarded-for') || 'default',
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
- keyGenerator: (req) => req.user?.id || req.headers.get('x-forwarded-for'),
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) => req.headers.get('x-forwarded-for'),
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