The Problem with Trusting Keys Alone
The src/sse/handlers/chat.js file is the heart of a real-time AI chat service. It receives requests, streams Server-Sent Events back to clients, and — critically — calls a paid AI provider on every invocation. Before this fix, the exported handleChat function did one thing to protect itself: it checked an API key. That sounds reasonable until you ask a follow-up question: what stops a valid key from being used ten thousand times a minute?
Nothing. That was the vulnerability.
The Vulnerability Explained
What the code looked like before the fix
Before the patch, handleChat jumped straight into parsing the request body after a single authentication check:
// BEFORE — src/sse/handlers/chat.js (vulnerable)
export async function handleChat(request, clientRawRequest = null) {
let body;
try {
body = await request.json();
// ... format detection, AI provider call, SSE streaming
}
}
There is no check on how often a given IP address or token can reach this line. The function happily calls request.json(), detects the AI format, forwards the payload to the upstream provider, and streams the response — every single time, for every single caller, at any frequency.
Why this is dangerous for THIS endpoint specifically
SSE chat endpoints are expensive in two distinct ways:
- Server resources — Each request holds an open HTTP connection for the duration of the streamed response. Hundreds of concurrent connections saturate file descriptors, memory, and CPU.
- Paid AI provider quotas — Every call to the upstream AI API costs money and counts against rate limits imposed by the provider. An attacker (or a misconfigured client) can drain a monthly quota in minutes.
A concrete attack scenario
An attacker discovers the /sse/chat endpoint. They have — or have stolen — one valid API key. They write a trivial loop:
while true; do
curl -s -X POST https://example.com/sse/chat \
-H "Authorization: Bearer VALID_KEY" \
-d '{"messages":[{"role":"user","content":"hello"}]}' &
done
Within seconds, hundreds of concurrent requests are in flight. The server's connection pool fills. Legitimate users see timeouts. Meanwhile, the AI provider's billing dashboard shows thousands of tokens consumed per minute — all charged to the application owner. The attacker has achieved both denial-of-service and financial damage with a one-liner.
The scanner flagged this at line 34 of chat.js, the exact entry point of handleChat, because no throttling guard exists before the expensive work begins.
The Fix
What changed
The fix adds three things to chat.js:
- An import for
getClientIpfrom the existing auth library. - An in-memory sliding-window rate limiter (
_checkChatRateLimit) defined at module scope. - An early-exit guard at the very top of
handleChatthat invokes the limiter and returns HTTP 429 before any body parsing or AI calls occur.
Before vs. After
Before (no throttling):
export async function handleChat(request, clientRawRequest = null) {
let body;
try {
body = await request.json();
// expensive AI call follows ...
}
}
After (rate-limited):
import { getClientIp } from "@/lib/auth/loginLimiter.js";
// In-memory sliding-window rate limiter for chat endpoint
const _rlMap = new Map();
const RL_WINDOW_MS = 60_000; // 1-minute window
const RL_MAX_REQ = 60; // 60 requests per window per IP
function _checkChatRateLimit(ip) {
const now = Date.now();
const entry = _rlMap.get(ip) || { count: 0, windowStart: now };
if (now - entry.windowStart > RL_WINDOW_MS) {
entry.count = 1; entry.windowStart = now;
} else {
entry.count += 1;
}
_rlMap.set(ip, entry);
return entry.count > RL_MAX_REQ;
}
export async function handleChat(request, clientRawRequest = null) {
const clientIp = getClientIp(request);
if (_checkChatRateLimit(clientIp)) {
log.warn("CHAT", `Rate limit exceeded for IP: ${clientIp}`);
return errorResponse(HTTP_STATUS.RATE_LIMITED, "Too many requests");
}
let body;
try {
body = await request.json();
// ...
}
}
How the sliding window works
_rlMap is a Map keyed by client IP. Each entry stores:
- count — how many requests have arrived in the current window.
- windowStart — the timestamp when the current window opened.
On each call, _checkChatRateLimit checks whether more than RL_WINDOW_MS (60 seconds) has elapsed since windowStart. If yes, the window resets and the count starts at 1. If no, the count increments. The function returns true (i.e., block this request) when count exceeds RL_MAX_REQ (60).
The guard fires before request.json(), which means no body is parsed, no AI provider is contacted, and no SSE connection is opened for rate-limited callers. The expensive work never starts.
Why reuse getClientIp from loginLimiter.js
The project already had IP-extraction logic in @/lib/auth/loginLimiter.js for protecting the login endpoint. Reusing it ensures consistent behavior across the application — including correct handling of X-Forwarded-For headers in proxied deployments — without duplicating logic.
Prevention & Best Practices
1. Apply rate limiting as close to the entry point as possible
Rate limiting should be the first check, before authentication, body parsing, or any downstream calls. If it fires after body parsing, you've already spent CPU on the attacker's payload.
2. Scope limits to the right key
Per-IP limiting (as implemented here) is a good baseline. For authenticated APIs, consider also limiting per API key or per user ID so that users behind a shared NAT aren't penalized for each other's behavior.
3. Use sliding windows, not fixed windows
Fixed windows (e.g., "60 requests per clock minute") can be gamed by bursting at the boundary — 60 requests at 00:59 and 60 more at 01:00. Sliding windows measure the last N milliseconds from now, eliminating the burst gap.
4. Persist state for multi-instance deployments
The in-memory Map used here works for a single process. In a horizontally scaled deployment, use a shared store (Redis with INCR + EXPIRE, or a distributed rate-limiter like rate-limiter-flexible) so limits apply across all instances.
5. Return the right HTTP status
Always return HTTP 429 Too Many Requests with a Retry-After header when throttling. Returning 403 or 503 gives clients misleading signals and makes debugging harder.
6. Monitor and alert
Log every rate-limit hit (as the fix does with log.warn("CHAT", ...)) and feed those logs into your alerting pipeline. A spike in 429s is an early signal of abuse or a misconfigured client.
Relevant standards
- OWASP API Security Top 10 — API4:2023: Unrestricted Resource Consumption
- CWE-770: Allocation of Resources Without Limits or Throttling
- OWASP Rate Limiting Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Denial_of_Service_Cheat_Sheet.html
Key Takeaways
handleChatinchat.jsprocessed every request unconditionally — API key validation confirmed who was calling, but nothing limited how often they could call.- SSE chat endpoints are doubly expensive: they hold long-lived connections and trigger paid AI provider calls, making them high-value DoS targets.
- The fix gates on IP before any body parsing —
_checkChatRateLimit(clientIp)is the very first statement inhandleChat, ensuring zero wasted work on blocked requests. - Reusing
getClientIpfromloginLimiter.jskept IP-extraction logic consistent and proxy-aware across the whole application. - In-memory sliding windows are a valid first step, but production multi-instance deployments should migrate this state to Redis or a similar shared store.
How Orbis AppSec Detected This
- Source: Every inbound HTTP request to the
/sse/chatroute — no client-side constraint on call frequency. - Sink: The
handleChatfunction entry point atsrc/sse/handlers/chat.js:34, which immediately proceeds to parse the body and invoke the AI provider without any throttle check. - Missing control: No per-IP or per-token request frequency check existed between the network boundary and the expensive processing logic. API key validation was present but does not limit call rate.
- CWE: CWE-770 — Allocation of Resources Without Limits or Throttling.
- Fix: Added
_checkChatRateLimit(ip)— a sliding-window limiter (60 req/60 s per IP) — as the first statement inhandleChat, returning HTTP 429 before any body parsing or AI calls occur.
Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.
Conclusion
Rate limiting is one of those controls that feels optional right up until the moment it isn't. The handleChat vulnerability is a textbook example of an endpoint that was authenticated but not throttled — a distinction that matters enormously when the downstream cost of each request is measured in dollars and server connections. The fix is surgical: eleven lines of code, zero changes to business logic, and a hard ceiling on how much damage any single IP can do in a 60-second window. If you maintain any endpoint that proxies to a paid external service or holds long-lived connections, audit it today for the same pattern.