feat(gateway): add general per-IP request rate limiter#48336
feat(gateway): add general per-IP request rate limiter#48336TerminalsandCoffee wants to merge 1 commit intoopenclaw:mainfrom
Conversation
Adds a fixed-window per-IP request rate limiter that protects the gateway from request flooding when exposed via Tailscale Funnel or a public reverse proxy. Unlike the existing auth rate limiter (which tracks failed authentication attempts), this counts all incoming HTTP requests per client IP. - New module: request-rate-limit.ts with createRequestRateLimiter() - Fixed-window counter per IP (default: 120 req/min) - Loopback addresses exempt by default (local CLI unaffected) - Returns 429 + Retry-After header when limit exceeded - Periodic background pruning of expired entries - 13 unit tests covering counting, window expiry, IP isolation, loopback exemption, and cleanup Wired into createGatewayHttpServer() as an optional requestRateLimiter parameter, checked early in the request pipeline before all stages. Closes openclaw#20373 Co-Authored-By: Claude Opus 4.6 <[email protected]>
Greptile SummaryThis PR adds a well-structured per-IP fixed-window request rate limiter to the gateway, mirroring the existing Key findings:
Confidence Score: 2/5
Prompt To Fix All With AIThis is a comment left during a code review.
Path: src/gateway/server-http.ts
Line: 772
Comment:
**Rate limiter bypassed entirely when behind a reverse proxy**
The limiter is called with `req.socket?.remoteAddress`, which is the TCP peer address — the IP of the reverse proxy, not the actual client. For the most common "reverse proxy" deployment scenario mentioned in the PR description, this means:
1. If the proxy runs on loopback (`127.0.0.1`), `isLoopbackAddress` returns `true` and every request is exempted from rate limiting.
2. If the proxy is at any other private address, all clients share that single counter, so the first client to hit 120 req/min blocks everyone.
The true client IP must be resolved first (reading `X-Forwarded-For` / `X-Real-IP` against the configured `trustedProxies`). The helper `resolveRequestClientIp` already does this correctly, but it requires the config snapshot. Moving the rate-limit check to after the config is loaded and the client IP is resolved would fix this:
```typescript
// After loading configSnapshot and resolving trustedProxies / allowRealIpFallback:
const clientIp = resolveRequestClientIp(req, trustedProxies, allowRealIpFallback);
if (requestRateLimiter) {
const result = requestRateLimiter.check(clientIp);
if (!result.allowed) {
const retryAfter = result.retryAfterMs > 0 ? Math.ceil(result.retryAfterMs / 1000) : 1;
res.setHeader("Retry-After", String(retryAfter));
sendText(res, 429, "Too Many Requests");
return;
}
}
```
Without this fix, the feature only works correctly for direct (non-proxied) connections.
How can I resolve this? If you propose a fix, please make it concise.Last reviewed commit: 5cc6be7 |
|
|
||
| // General per-IP request rate limiting (flood protection). | ||
| if (requestRateLimiter) { | ||
| const result = requestRateLimiter.check(req.socket?.remoteAddress); |
There was a problem hiding this comment.
Rate limiter bypassed entirely when behind a reverse proxy
The limiter is called with req.socket?.remoteAddress, which is the TCP peer address — the IP of the reverse proxy, not the actual client. For the most common "reverse proxy" deployment scenario mentioned in the PR description, this means:
- If the proxy runs on loopback (
127.0.0.1),isLoopbackAddressreturnstrueand every request is exempted from rate limiting. - If the proxy is at any other private address, all clients share that single counter, so the first client to hit 120 req/min blocks everyone.
The true client IP must be resolved first (reading X-Forwarded-For / X-Real-IP against the configured trustedProxies). The helper resolveRequestClientIp already does this correctly, but it requires the config snapshot. Moving the rate-limit check to after the config is loaded and the client IP is resolved would fix this:
// After loading configSnapshot and resolving trustedProxies / allowRealIpFallback:
const clientIp = resolveRequestClientIp(req, trustedProxies, allowRealIpFallback);
if (requestRateLimiter) {
const result = requestRateLimiter.check(clientIp);
if (!result.allowed) {
const retryAfter = result.retryAfterMs > 0 ? Math.ceil(result.retryAfterMs / 1000) : 1;
res.setHeader("Retry-After", String(retryAfter));
sendText(res, 429, "Too Many Requests");
return;
}
}Without this fix, the feature only works correctly for direct (non-proxied) connections.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/gateway/server-http.ts
Line: 772
Comment:
**Rate limiter bypassed entirely when behind a reverse proxy**
The limiter is called with `req.socket?.remoteAddress`, which is the TCP peer address — the IP of the reverse proxy, not the actual client. For the most common "reverse proxy" deployment scenario mentioned in the PR description, this means:
1. If the proxy runs on loopback (`127.0.0.1`), `isLoopbackAddress` returns `true` and every request is exempted from rate limiting.
2. If the proxy is at any other private address, all clients share that single counter, so the first client to hit 120 req/min blocks everyone.
The true client IP must be resolved first (reading `X-Forwarded-For` / `X-Real-IP` against the configured `trustedProxies`). The helper `resolveRequestClientIp` already does this correctly, but it requires the config snapshot. Moving the rate-limit check to after the config is loaded and the client IP is resolved would fix this:
```typescript
// After loading configSnapshot and resolving trustedProxies / allowRealIpFallback:
const clientIp = resolveRequestClientIp(req, trustedProxies, allowRealIpFallback);
if (requestRateLimiter) {
const result = requestRateLimiter.check(clientIp);
if (!result.allowed) {
const retryAfter = result.retryAfterMs > 0 ? Math.ceil(result.retryAfterMs / 1000) : 1;
res.setHeader("Retry-After", String(retryAfter));
sendText(res, 429, "Too Many Requests");
return;
}
}
```
Without this fix, the feature only works correctly for direct (non-proxied) connections.
How can I resolve this? If you propose a fix, please make it concise.There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5cc6be71ac
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
|
|
||
| // General per-IP request rate limiting (flood protection). | ||
| if (requestRateLimiter) { | ||
| const result = requestRateLimiter.check(req.socket?.remoteAddress); |
There was a problem hiding this comment.
Resolve real client IP before enforcing request limits
This limiter keys requests by req.socket.remoteAddress, but in reverse-proxy deployments that value is the proxy hop, not the end user. In the same handler we already load gateway.trustedProxies/allowRealIpFallback later for auth paths, so rate limiting here either bypasses protection entirely when the proxy connects from loopback (default loopback exemption), or makes all users behind one proxy share a single bucket and get cross-user 429s. Compute the client IP with resolveRequestClientIp(...) (falling back to socket address) before calling requestRateLimiter.check.
Useful? React with 👍 / 👎.
Summary
RequestRateLimitConfig429 Too Many RequestswithRetry-Afterheader when limit exceededrequestRateLimiterparameter — checked early, before all request stagesFiles
src/gateway/request-rate-limit.tscreateRequestRateLimiter()with fixed-window countersrc/gateway/request-rate-limit.test.tssrc/gateway/server-http.tsrequestRateLimiterinhandleRequest()src/gateway/server-runtime-state.tsrequestRateLimiterthrough to HTTP server creationDesign
auth-rate-limit.tsAPI shape (check/size/prune/dispose)Test plan
pnpm vitest run src/gateway/request-rate-limit.test.ts— 13/13 passingpnpm vitest run src/gateway/auth-rate-limit.test.ts— 21/21 passing (no regression)Closes #20373
🤖 Generated with Claude Code