Skip to content

Commit 3947c64

Browse files
committed
fix: improve rate limiter IP detection with socket.remoteAddress fallback and type declarations
- Add req.socket?.remoteAddress as third fallback in defaultKeyGenerator (after req.ip and req.remoteAddress) to bridge the gap between test patterns and the actual default implementation - Add ip?, remoteAddress?, socket?, and rateLimit? to ZeroRequest type in common.d.ts so TypeScript users can work with connection-level properties without type errors - Add missing current/reset properties to ctx.rateLimit type to match the runtime shape set by the rate-limit middleware - Add concrete Bun.serve example in README showing how to populate req.ip via server.requestIP() before rate limiting - Add 3 new unit tests validating the socket.remoteAddress fallback and priority ordering in defaultKeyGenerator - Update all documentation references to reflect the expanded fallback chain: req.ip || req.remoteAddress || req.socket?.remoteAddress
1 parent 1ad851f commit 3947c64

6 files changed

Lines changed: 99 additions & 8 deletions

File tree

README.md

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,32 @@ Bun.serve({
7373
})
7474
```
7575

76+
### Enabling Client IP for Rate Limiting
77+
78+
Bun's standard `Request` object does not expose the client IP address. To enable the default rate limiter key generator (and any middleware that reads `req.ip`), use `server.requestIP()` in the `Bun.serve` fetch handler:
79+
80+
```typescript
81+
import http from '0http-bun'
82+
import {createRateLimit} from '0http-bun/lib/middleware'
83+
84+
const {router} = http()
85+
86+
router.use(createRateLimit({windowMs: 15 * 60 * 1000, max: 100}))
87+
88+
router.get('/', () => new Response('Hello World!'))
89+
90+
// Populate req.ip from Bun's server.requestIP before passing to the router
91+
Bun.serve({
92+
port: 3000,
93+
fetch(req, server) {
94+
req.ip = server.requestIP(req)?.address
95+
return router.fetch(req)
96+
},
97+
})
98+
```
99+
100+
> **Why is this needed?** The Fetch API `Request` type does not include connection-level properties like `ip`. Bun provides client IP via `server.requestIP(req)` in the fetch handler's second argument. Setting `req.ip` before calling `router.fetch` ensures the rate limiter (and other middleware) can identify clients correctly. Without this, the default key generator falls back to unique per-request keys, which effectively disables rate limiting.
101+
76102
### With TypeScript Types
77103

78104
```typescript
@@ -357,7 +383,7 @@ router.get('/api/risky', async (req: ZeroRequest) => {
357383

358384
#### **Rate Limiting**
359385

360-
- **Secure Key Generation**: Default key generator uses `req.ip || req.remoteAddress || 'unknown'` — proxy headers are **not trusted** by default
386+
- **Secure Key Generation**: Default key generator uses `req.ip || req.remoteAddress || req.socket?.remoteAddress || 'unknown'` — proxy headers are **not trusted** by default
361387
- **No Store Injection**: Rate limit store is always the constructor-configured instance (no `req.rateLimitStore` override)
362388
- **Bounded Memory**: Sliding window rate limiter enforces `maxKeys` (default: 10,000) with periodic cleanup
363389
- **Synchronous Increment**: `MemoryStore.increment` is synchronous to eliminate TOCTOU race conditions
@@ -435,7 +461,8 @@ router.use(
435461
req: (req) => ({
436462
method: req.method,
437463
url: req.url,
438-
ip: req.ip || req.remoteAddress || 'unknown',
464+
ip:
465+
req.ip || req.remoteAddress || req.socket?.remoteAddress || 'unknown',
439466
userAgent: req.headers.get('user-agent'),
440467
}),
441468
},

common.d.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,19 @@ export interface ParsedFile {
2020
export type ZeroRequest = Request & {
2121
params: Record<string, string>
2222
query: Record<string, string>
23+
// Connection-level IP address (set via Bun.serve's server.requestIP or upstream middleware)
24+
ip?: string
25+
remoteAddress?: string
26+
socket?: {
27+
remoteAddress?: string
28+
}
29+
// Rate limit info (set by rate-limit middleware)
30+
rateLimit?: {
31+
limit: number
32+
remaining: number
33+
current: number
34+
reset: Date
35+
}
2336
// Legacy compatibility properties (mirrored from ctx)
2437
user?: any
2538
jwt?: {
@@ -43,6 +56,8 @@ export type ZeroRequest = Request & {
4356
used: number
4457
remaining: number
4558
resetTime: Date
59+
current: number
60+
reset: Date
4661
}
4762
body?: any
4863
files?: Record<string, ParsedFile | ParsedFile[]>

lib/middleware/README.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -854,7 +854,7 @@ router.use(
854854
max: 20, // Max requests
855855
keyGenerator: (req) => {
856856
// Custom key generation
857-
// Default uses: req.ip || req.remoteAddress || 'unknown'
857+
// Default uses: req.ip || req.remoteAddress || req.socket?.remoteAddress || 'unknown'
858858
// NOTE: Proxy headers are NOT trusted by default. If behind a
859859
// reverse proxy, you MUST provide a custom keyGenerator:
860860
return (
@@ -911,7 +911,7 @@ const rateLimitOptions: RateLimitOptions = {
911911
windowMs: 15 * 60 * 1000, // 15 minutes
912912
max: 100,
913913
keyGenerator: (req) => {
914-
// Default: req.ip || req.remoteAddress || 'unknown'
914+
// Default: req.ip || req.remoteAddress || req.socket?.remoteAddress || 'unknown'
915915
// Custom: read from proxy-set header if behind a reverse proxy
916916
return (
917917
req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
@@ -952,7 +952,8 @@ router.use(
952952
windowMs: 60 * 1000, // 1 minute sliding window
953953
max: 10, // Max 10 requests per minute
954954
maxKeys: 10000, // Maximum tracked keys (default: 10000) — prevents unbounded memory growth
955-
keyGenerator: (req) => req.ip || req.remoteAddress || 'unknown',
955+
keyGenerator: (req) =>
956+
req.ip || req.remoteAddress || req.socket?.remoteAddress || 'unknown',
956957
}),
957958
)
958959
```
@@ -1014,7 +1015,8 @@ router.use(
10141015
createSlidingWindowRateLimit({
10151016
windowMs: 3600 * 1000, // 1 hour
10161017
max: 3, // 3 accounts per IP per hour
1017-
keyGenerator: (req) => req.ip || req.remoteAddress || 'unknown',
1018+
keyGenerator: (req) =>
1019+
req.ip || req.remoteAddress || req.socket?.remoteAddress || 'unknown',
10181020
}),
10191021
)
10201022

lib/middleware/rate-limit.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,7 @@ function createRateLimit(options = {}) {
173173

174174
/**
175175
* Default key generator - uses connection-level IP address
176+
* Checks req.ip, req.remoteAddress, and req.socket?.remoteAddress in order.
176177
* NOTE: If behind a reverse proxy, provide a custom keyGenerator that
177178
* reads the appropriate header after configuring your proxy to set it.
178179
* @param {Request} req - Request object
@@ -181,8 +182,8 @@ function createRateLimit(options = {}) {
181182
let _unknownKeyWarned = false
182183

183184
function defaultKeyGenerator(req) {
184-
// Use connection-level IP if available (set by Bun's server)
185-
const ip = req.ip || req.remoteAddress
185+
// Use connection-level IP if available (set by Bun's server or upstream middleware)
186+
const ip = req.ip || req.remoteAddress || req.socket?.remoteAddress
186187
if (ip) return ip
187188

188189
// I-1: Generate unique key per request to avoid shared bucket DoS

test-types.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,20 @@ const testRequestTypes = async (req: ZeroRequest): Promise<Response> => {
122122
const params: Record<string, string> = req.params
123123
const query: Record<string, string> = req.query
124124

125+
// Test connection-level IP properties
126+
const ip: string | undefined = req.ip
127+
const remoteAddress: string | undefined = req.remoteAddress
128+
const socketAddress: string | undefined = req.socket?.remoteAddress
129+
130+
// Test top-level rate limit info (set by rate-limit middleware)
131+
const topLevelRateLimit = req.rateLimit
132+
if (topLevelRateLimit) {
133+
const limit: number = topLevelRateLimit.limit
134+
const remaining: number = topLevelRateLimit.remaining
135+
const current: number = topLevelRateLimit.current
136+
const reset: Date = topLevelRateLimit.reset
137+
}
138+
125139
// Test context object
126140
const ctx = req.ctx
127141
const log = ctx?.log

test/unit/rate-limit.test.js

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -501,6 +501,38 @@ describe('Rate Limit Middleware', () => {
501501
expect(key).toBe('5.6.7.8')
502502
})
503503

504+
it('should use req.socket.remoteAddress as last IP fallback', () => {
505+
const testReq = {
506+
socket: {remoteAddress: '10.0.0.1'},
507+
headers: new Headers([['x-forwarded-for', '13.14.15.16']]),
508+
}
509+
510+
const key = defaultKeyGenerator(testReq)
511+
expect(key).toBe('10.0.0.1')
512+
})
513+
514+
it('should prefer req.ip over req.socket.remoteAddress', () => {
515+
const testReq = {
516+
ip: '1.2.3.4',
517+
socket: {remoteAddress: '10.0.0.1'},
518+
headers: new Headers(),
519+
}
520+
521+
const key = defaultKeyGenerator(testReq)
522+
expect(key).toBe('1.2.3.4')
523+
})
524+
525+
it('should prefer req.remoteAddress over req.socket.remoteAddress', () => {
526+
const testReq = {
527+
remoteAddress: '5.6.7.8',
528+
socket: {remoteAddress: '10.0.0.1'},
529+
headers: new Headers(),
530+
}
531+
532+
const key = defaultKeyGenerator(testReq)
533+
expect(key).toBe('5.6.7.8')
534+
})
535+
504536
it('should not trust proxy headers by default', () => {
505537
const testReq = {
506538
headers: new Headers([['x-forwarded-for', '9.10.11.12, 13.14.15.16']]),

0 commit comments

Comments
 (0)