Introduction
In a Node.js WebSocket cross-server adapter library, we discovered a critical resource exhaustion vulnerability in src/WebSocketCrossServerAdapter.js that could allow attackers to degrade or disable distributed WebSocket services through simple message flooding. The WebSocketCrossServerAdapter class handles message routing between WebSocket servers and Redis backends for distributed deployments, but it processed every single inbound message without any rate limiting — creating a perfect denial-of-service vector.
The vulnerability was particularly dangerous because this adapter is designed for horizontal scaling scenarios. An attacker connecting to any single node could generate enough traffic to cascade through the entire Redis cluster, potentially affecting all connected WebSocket servers and their clients. With no rateLimit option in the constructor and no internal throttling mechanism, the code at line 1 and throughout the message handling path accepted unlimited messages from any authenticated (or even unauthenticated) WebSocket connection.
The Vulnerability Explained
The core issue was architectural: the WebSocketCrossServerAdapter class implemented message listeners without any ingestion controls. Here's what the vulnerable message handling pattern looked like conceptually:
// VULNERABLE: No rate limiting on message processing
class WebSocketCrossServerAdapter {
constructor(options) {
// Missing: rateLimit configuration
this.setupWebSocketHandlers();
}
setupWebSocketHandlers() {
this.wss.on('connection', (ws) => {
ws.on('message', (data) => {
// EVERY message processed immediately, no throttling
this.processMessage(data); // CPU work
this.publishToRedis(data); // Network I/O to Redis
this.broadcastToRoom(data); // Fan-out to other clients
});
});
}
}
The specific attack path:
- Attacker connects to any WebSocket endpoint using
WebSocketCrossServerAdapter - Floods messages — the attacker sends 10,000+ messages per second (easily achievable with a simple script)
- Each message triggers:
- JSON parsing and validation (CPU)
- RedisPUBLISHcommand (network I/O, Redis CPU)
- Potential broadcast to room members (multiplied fan-out) - Resource exhaustion — Redis nodes become saturated, WebSocket server event loop blocks, memory grows unbounded with pending operations
- Cascading failure — other legitimate connections timeout, cluster rebalancing fails, service degrades across all nodes
The vulnerability was especially insidious because heartbeat messages counted toward the flood. Many WebSocket implementations send frequent pings; a malicious client could exploit this by sending artificial "heartbeat-like" traffic that the server couldn't distinguish from legitimate keepalive traffic.
The Fix
The fix introduces a configurable per-connection rate limit with sensible defaults. Here's the change implemented across the codebase:
Documentation Updates (README.md, README.zh-CN.md, api.en-US.md)
The fix begins with documentation transparency — users need to know this protection exists:
+ Built-in per-connection inbound message rate limiting (`rateLimit` option) to guard against message-flooding clients
And the detailed API documentation in api.en-US.md:
- `rateLimit` `{number}`: (Optional) Maximum number of inbound WebSocket messages accepted per socket, per second. Default is `100`.
**Description**: Limits how many messages — including heartbeat messages — a single WebSocket connection may send within a 1-second window. Excess messages are silently dropped for the remainder of that window; the counter resets automatically once the window elapses.
This is a per-connection ingress safeguard only — it does **not** limit Redis publishes triggered by business-message listeners (e.g. via `broadcastToRoom`, `broadcast`, `emitCrossServer`). Redis-level publish throttling is not covered by this option.
Set to `0` to disable rate limiting entirely.
⚠️ **Behavior change**: This option defaults to `100`.
Implementation in WebSocketCrossServerAdapter.js
The actual implementation adds rate limiting logic to the message handler:
// SECURE: With per-connection rate limiting
class WebSocketCrossServerAdapter {
constructor(options) {
// NEW: rateLimit configuration with safe default
this.rateLimit = options.rateLimit !== undefined ? options.rateLimit : 100;
this.setupWebSocketHandlers();
}
setupWebSocketHandlers() {
this.wss.on('connection', (ws) => {
// NEW: Per-connection rate limit state
const connectionState = {
messageCount: 0,
windowStart: Date.now()
};
ws.on('message', (data) => {
// NEW: Rate limit check
if (this.rateLimit > 0) {
const now = Date.now();
if (now - connectionState.windowStart >= 1000) {
// Reset window
connectionState.windowStart = now;
connectionState.messageCount = 0;
}
if (connectionState.messageCount >= this.rateLimit) {
// Silently drop excess messages
return;
}
connectionState.messageCount++;
}
// Process message only if within limit
this.processMessage(data);
this.publishToRedis(data);
});
});
}
}
Key security improvements:
| Aspect | Before | After |
|---|---|---|
| Default protection | None | 100 msg/sec per connection |
| Configurability | N/A | rateLimit option (0 = unlimited) |
| Scope | N/A | Per-connection, 1-second sliding window |
| Excess handling | Process all | Silently drop, no error amplification |
| Heartbeat protection | None | All messages counted (prevents bypass) |
The fix uses silent dropping rather than connection termination to prevent error-response amplification attacks — where attackers could trigger additional resource consumption through error handling paths.
Prevention & Best Practices
To prevent similar vulnerabilities in your WebSocket implementations:
1. Implement Defense in Depth
Rate limiting at multiple layers:
- Per-connection (as implemented here)
- Per-IP (using middleware like express-rate-limit or ws-rate-limit)
- Global (service-level circuit breakers)
2. Distinguish Message Types Carefully
If your protocol has heartbeat messages, consider:
- Separate rate limits for control vs. data messages
- Token-bucket algorithms for burst tolerance
- Exponential backoff for repeated violations
3. Monitor and Alert
Track these metrics:
- Messages per second per connection (anomaly detection)
- Drop rates due to rate limiting (tuning validation)
- Redis publish latency (downstream impact indicator)
4. Secure Defaults
Always ship with protective defaults:
- Never require opt-in for basic DoS protection
- Document behavior changes in release notes
- Provide escape hatch (rateLimit: 0) for special cases
Relevant Standards
- CWE-770: Allocation of Resources Without Limits or Throttling
- CWE-400: Uncontrolled Resource Consumption
- OWASP WebSocket Security: https://cheatsheetseries.owasp.org/cheatsheets/WebSocket_Security_Cheat_Sheet.html
Key Takeaways
- The
WebSocketCrossServerAdapterconstructor now requires an explicitrateLimitdecision — the default of 100 messages/second per connection prevents accidental unbounded consumption while remaining generous for real-time applications - Heartbeat messages must count toward rate limits — attackers can exploit any message-type exclusion by wrapping attack traffic in allowed message types
- Silent dropping prevents error amplification — returning errors to flood attackers creates additional work; dropping excess messages with no response is more efficient
- Per-connection state isolation prevents cross-user impact — rate limit counters are scoped to individual WebSocket connections, ensuring one attacker cannot exhaust limits for others
How Orbis AppSec Detected This
- Source: Untrusted data enters through the WebSocket
messageevent listener insrc/WebSocketCrossServerAdapter.js, which receives arbitrary client-controlled message payloads - Sink: The message handler immediately processes and forwards messages to Redis via
publishToRedis()and potentially broadcasts to room members, with no throttling on execution frequency - Missing control: No rate limiting, token bucket, or message queue backpressure mechanism existed to constrain resource consumption per connection
- CWE: CWE-770 (Allocation of Resources Without Limits or Throttling)
- Fix: Added constructor option
rateLimitwith 100 msg/sec default, implementing per-connection sliding window counters that silently drop excess messages
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
The unbounded message handling in WebSocketCrossServerAdapter.js exemplifies how even mature distributed systems can lack basic resource protections. The fix demonstrates that effective rate limiting doesn't require complex infrastructure — a simple per-connection counter with a 1-second window provides meaningful protection against flooding attacks while preserving the real-time responsiveness that makes WebSocket architectures valuable.
When building scalable WebSocket systems, always assume malicious clients will exploit any unbounded resource. The rateLimit option added here provides that essential boundary, and its default-on behavior ensures protection without requiring developer action.
References
- CWE-770: Allocation of Resources Without Limits or Throttling — https://cwe.mitre.org/data/definitions/770.html
- OWASP WebSocket Security Cheat Sheet — https://cheatsheetseries.owasp.org/cheatsheets/WebSocket_Security_Cheat_Sheet.html
- Node.js
wslibrary documentation on rate limiting patterns — https://github.com/websockets/ws/blob/master/doc/ws.md - Semgrep rule for unbounded WebSocket message handling — https://semgrep.dev/r?q=javascript.websocket.security.audit.websocket-missing-rate-limit
- fix: the websocketcrossserveradapter class does not ... in...