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.


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.


Prevention and further reading

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #1

Related Articles

critical

deleteNestedProperty Prototype Pollution via Dot-Notation Path

The `deleteNestedProperty` function in propertyUtils.ts allowed attackers to manipulate JavaScript object prototypes by passing specially crafted dot-notation paths like `__proto__.polluted`. A fix now blocks dangerous keys before processing, preventing prototype pollution attacks that could affect all objects in the application.

high

How Denial of Service via Infinite Loop Happens in JavaScript Dependencies and How to Fix It

CVE-2026-67213 is a high-severity denial of service vulnerability in nanoid before version 5.1.6 that triggers an infinite loop during random ID generation when processing specially crafted input. We upgraded nanoid across the entire dependency tree to patch this flaw and prevent attackers from freezing application threads. This fix ensures that ID generation remains resilient even when handling adversarial input patterns.

high

How Sensitive Data Exposure happens in Zotero plugins and how to fix it

A high-severity data exposure vulnerability in `Zotero.ts` automatically transmitted complete document metadata—including private notes, attachment paths, and tags—to external LLM services without user consent. The fix replaces broad `item.toJSON()` serialization with explicit field selection, sending only essential bibliographic data.

high

How missing dependency update cooldowns happen in GitHub Dependabot configurations and how to fix it

A semgrep scan flagged `.github/dependabot.yml` for lacking a cooldown period, meaning Dependabot would immediately propose updates to brand-new package versions across npm, Bundler, and Docker ecosystems. The fix adds a `cooldown: default-days: 7` block to every `package-ecosystem` entry, forcing a one-week waiting period before newly published releases are considered — reducing exposure to malicious or unstable package drops.

high

How Path Traversal Happens in TensorFlow's Data Service and How to Fix It

TensorFlow's data service dispatcher validated dataset IDs against forward-slash traversal attacks but overlooked backslash characters on non-Windows platforms, allowing attackers to escape the root directory. A targeted fix adds explicit backslash validation across all platforms, closing a high-severity path traversal vulnerability in the snapshot management system.

critical

How Unbounded WebSocket Message Handling Causes Resource Exhaustion in Node.js and How to Fix It

The WebSocketCrossServerAdapter class in a popular Node.js WebSocket library lacked any rate limiting on inbound messages, allowing attackers to flood Redis nodes and WebSocket servers with high-volume traffic. The fix introduces a configurable `rateLimit` option that caps messages per connection per second, preventing resource exhaustion while preserving legitimate functionality.