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
•Technically reviewed by Anupam Mediratta•Published August 26, 2026•Reviewed 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 detects issues like this automatically. 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

Related Articles

medium

path-to-regexp 0.1.12 DoS: Catastrophic Backtracking on Malformed URL

path-to-regexp version 0.1.12 contains a regular expression engine vulnerability where maliciously crafted URL parameters trigger exponential backtracking, causing CPU exhaustion and service unavailability. Upgrading to version 0.1.13 eliminates the vulnerable pattern from the parsing logic.

critical

CVE-2026-59873: node-tar 7.5.11 DoS via Crafted Gzip Bomb

node-tar versions 7.5.11 through 7.5.18 are vulnerable to a denial-of-service attack through maliciously crafted gzip archives that decompress to disproportionately large sizes. An attacker can exploit this to exhaust memory and CPU resources by submitting a small, highly compressed archive that expands beyond configured limits during extraction.

high

MapManager.get() Race Condition Duplicates API Requests

The MapManager's `get(mapUid, cache)` method used a check-then-act pattern that permitted multiple concurrent requests to pass the cache miss check simultaneously, triggering redundant API calls and risking cache corruption. The fix introduces a `_pending` promise map to deduplicate in-flight fetches for identical map UIDs.

critical

Lampa Desktop Auto-Update Heuristic Bypass: Execution of Unverified

Lampa Desktop's auto-update mechanism downloaded JavaScript and CSS from `raw.githubusercontent.com` using only heuristic validation—file size thresholds and string pattern matching—that attackers could trivially satisfy. The fix introduces cryptographic integrity verification by cross-referencing Git blob hashes from the GitHub Contents API, ensuring downloaded code matches the repository's authoritative state before execution.

high

adm-zip 0.6.0 Preserves SUID Bits From ZIPs: CVE-2026-102282

The `adm-zip` dependency resolved to 0.6.0 in this project's dependency tree, a version affected by CVE-2026-102282: during extraction it applies the Unix permission bits stored in each ZIP entry's external file attributes verbatim, including the setuid (`04000`), setgid (`02000`), and sticky bits. An attacker who controls an archive passed to `extractAllTo()` or `extractEntryTo()` can therefore have the extractor create a setuid binary owned by whatever user the extraction process runs as. The

high

requestInput() Type Confusion: NaN and Object Bypass in JavaScript

The `requestInput()` utility function lacked validation on its `type` parameter and failed to handle `NaN` results from float conversions, creating a type confusion weakness. An attacker could supply malformed inputs that propagate unhandled `NaN` values or unexpected object types through the type system. The fix adds explicit guards against `NaN` type parameters and rejects non-primitive type values.