Back to Blog
critical SEVERITY7 min read

How Missing Rate Limiting Happens in Node.js SSE Handlers and How to Fix It

A critical missing rate-limiting control in `src/sse/handlers/chat.js` allowed any caller to flood the SSE chat endpoint with unlimited requests, risking server resource exhaustion, denial of service, and runaway AI provider API costs. The fix introduces a per-IP sliding-window rate limiter that caps requests at 60 per minute and returns HTTP 429 on violations. Because the endpoint was publicly reachable and only validated API keys — not request frequency — exploitation required nothing more tha

O
By Orbis AppSec
Published August 26, 2026Reviewed August 26, 2026

Answer Summary

This vulnerability is a missing rate-limiting control (CWE-770: Allocation of Resources Without Limits or Throttling) in the Node.js `handleChat` function inside `src/sse/handlers/chat.js`. Without frequency throttling, any client could send unlimited requests to the SSE chat endpoint, exhausting server resources and consuming paid AI provider quotas. The fix adds an in-memory sliding-window rate limiter (`_checkChatRateLimit`) that allows a maximum of 60 requests per IP per 60-second window and returns HTTP 429 when the limit is exceeded.

Vulnerability at a Glance

cweCWE-770
fixAdded `_checkChatRateLimit(ip)` sliding-window limiter — 60 req/min per IP — before request processing begins
riskDenial of service, AI provider quota exhaustion, server resource starvation
languageJavaScript (Node.js)
root cause`handleChat` performed API key validation but applied no per-IP request frequency throttling
vulnerabilityMissing Rate Limiting on SSE Chat Endpoint

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:

  1. 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.
  2. 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:

  1. An import for getClientIp from the existing auth library.
  2. An in-memory sliding-window rate limiter (_checkChatRateLimit) defined at module scope.
  3. An early-exit guard at the very top of handleChat that 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

  • handleChat in chat.js processed 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 in handleChat, ensuring zero wasted work on blocked requests.
  • Reusing getClientIp from loginLimiter.js kept 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/chat route — no client-side constraint on call frequency.
  • Sink: The handleChat function entry point at src/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 in handleChat, 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.


References

Frequently Asked Questions

What is missing rate limiting in a web API?

Missing rate limiting means a server endpoint places no cap on how many times a single client can call it within a time window, allowing one actor to monopolize resources or trigger unbounded downstream costs.

How do you prevent rate limiting vulnerabilities in Node.js?

Implement per-IP (or per-token) request throttling using a sliding-window or token-bucket algorithm before processing begins. Libraries like `express-rate-limit` or a custom in-memory map both work; the key is rejecting excess requests with HTTP 429 before any expensive work runs.

What CWE is missing rate limiting?

CWE-770 — "Allocation of Resources Without Limits or Throttling." OWASP API Security Top 10 also lists it as API4:2023 Unrestricted Resource Consumption.

Is API key validation enough to prevent abuse?

No. API key validation confirms identity but says nothing about frequency. A legitimate key holder — or an attacker who obtains one key — can still flood the endpoint. Rate limiting must be layered on top of authentication.

Can static analysis detect missing rate limiting?

Yes. Tools like Semgrep can flag route handlers that lack calls to rate-limiter middleware or helper functions. Orbis AppSec's multi-agent scanner flagged this exact pattern in `chat.js` at line 34.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #1

Related Articles

high

How Dependabot Missing Cooldown happens in GitHub Actions and how to fix it

A high-severity misconfiguration in `.github/dependabot.yml` left this Node.js library without a cooldown period, meaning Dependabot would immediately propose updates to newly published packages — including potentially malicious or unstable ones. The fix adds a `cooldown` block with `default-days: 7` to both the `npm` and `github-actions` package ecosystem entries, introducing a mandatory 7-day waiting period before any new package version is surfaced as an update candidate.

critical

How CSRF Protection Failures Happen in FastAPI and How to Fix Them

A critical CORS misconfiguration in `backend/main.py` allowed cookies to be sent alongside wildcard-origin requests, violating the CORS specification and opening the door to cross-site request forgery attacks. The fix conditionally disables `allow_credentials` when the allowed origins list contains a wildcard, bringing the configuration into compliance with browser security rules. This change closes a subtle but dangerous gap that could have let attackers on sibling subdomains forge authenticate

medium

How Denial of Service via Catastrophic Backtracking happens in Node.js and how to fix it

CVE-2026-4867 is a Denial of Service vulnerability in path-to-regexp 0.1.12 where malformed URL parameters can trigger catastrophic backtracking in the library's regular expression engine, allowing an attacker to hang or crash a Node.js application with a single crafted request. The fix upgrades path-to-regexp to version 0.1.13, which patches the vulnerable regex patterns. This change was applied via a package-level override to ensure the patched version is used throughout the entire dependency

high

How Denial of Service via Exponential-Time Complexity happens in Node.js and how to fix it

CVE-2026-13149 is a high-severity Denial of Service vulnerability in the `brace-expansion` npm package, where crafted input strings trigger exponential-time processing that can freeze or crash a Node.js application. The fix upgrades `brace-expansion` from `2.0.2` to `2.1.4` and `minimatch` from `5.1.6` to `5.1.9`, along with npm `overrides` to ensure the patched versions are used throughout the entire dependency tree.

critical

How Unrestricted File Upload happens in Node.js/Express and how to fix it

A critical unrestricted file upload vulnerability was discovered in `mainsystem/routes/admin/profile.js`, where the avatar upload endpoint accepted any file type without validation. An authenticated attacker could upload a malicious server-side script to a web-accessible directory and execute arbitrary code on the server. The fix adds MIME type filtering, an allowlist of safe image formats, and a 2 MB file size limit to the multer middleware.

critical

How eval() Code Injection happens in JavaScript and how to fix it

A critical code injection vulnerability was discovered in `js/lib/jsencrypt.js` at line 195, where a direct `eval()` call executed a JavaScript string shim for the `process` object in browser environments. If an attacker could influence the string passed to `eval()`—through a compromised dependency, a man-in-the-middle attack, or supply chain tampering—they could achieve arbitrary JavaScript execution in any user's browser. The fix replaces the `eval()` call with the equivalent inline JavaScript