Repository navigation
Harden the socket.io server against connection floods and oversized messages #97
Description
Overview
The HTTP side of the backend has a reasonable baseline: helmet, an explicit CORS allowlist, a 1 MB JSON body limit and express-rate-limit limiters (globalLimiter 200 requests / 15 min per IP, authLimiter, generateLimiter). The socket.io server created in src/server.ts has none of this. It runs with library defaults: both polling and websocket transports, a 1 MB maxHttpBufferSize per inbound message, no limit on connections per IP or in total, no limit on inbound events per socket, and it logs every connect and disconnect at info level.
Because socket.io traffic is upgraded or long-polled outside the Express middleware chain, none of the HTTP rate limiters apply to it. A single client (or a small botnet) can open thousands of websocket connections and exhaust file descriptors and memory, keep thousands of polling sessions alive, send 1 MB frames, or simply connect and disconnect in a loop to flood the logs and the log pipeline. The CORS origin check on the socket server is also configured separately from the HTTP allowlist and only restricts browsers, not raw clients.
This issue makes the socket layer defensible: explicit transport and buffer limits, per-IP and global connection caps enforced across instances through Redis, per-socket inbound event rate limiting, origin validation identical to the HTTP CORS allowlist, sampled connection logging, and connection metrics. The hard parts are deriving the correct client IP behind proxies, avoiding false positives for users behind carrier-grade NAT, and proving the limits with adversarial tests.
Background & current behavior
src/server.ts:Noconst io = new Server(server, { cors: { origin: config.CORS_ORIGINS, methods: ['GET', 'POST'] } }); io.on('connection', (socket) => { logger.info({ socketId: socket.id }, 'New client connected to socket.io'); socket.on('disconnect', () => { logger.info({ socketId: socket.id }, 'Client disconnected from socket.io'); }); });
allowRequest, nomaxHttpBufferSize, notransports, noconnectTimeouttuning, no middleware (io.use).src/app.tsCORS: requests with noOriginare allowed (server-to-server), otherwiseconfig.CORS_ORIGINS.includes(origin). socket.io'scorsoption only sets response headers for polling/preflight; it does not reject websocket upgrades from other origins.src/middlewares/rate-limit.middleware.ts: express-rate-limit with the in-memory store, skipped whenNODE_ENV === 'test'; keyed byreq.ip. Express'strust proxyis not set anywhere, so behind a proxy every client shares the proxy's IP (the proxy-trust issue covers HTTP; sockets need the same derivation).- Clients never send application events today (the server only emits), so inbound events can be tightly limited without breaking the frontend; wave issue Feature/integration and fixes #4 will add a
subscribemessage. - There is no metrics endpoint yet (a Prometheus issue is planned separately).
Goals
- Explicit engine options:
maxHttpBufferSize,pingInterval,pingTimeout,connectTimeout, transport policy, andperMessageDeflatedisabled. - Origin enforcement for both polling and websocket upgrades using the same allowlist function as HTTP CORS.
- Per-IP and global concurrent connection caps, shared across instances via Redis, with local fallback when Redis is unavailable.
- Connection-rate limiting (new connections per IP per minute).
- Per-socket inbound event rate limiting and an allowlist of accepted inbound event names.
- Correct client IP behind configured trusted proxies.
- Sampled logging and connection counters exposed for metrics.
- Adversarial tests.
Non-goals
- Socket authentication (separate issue) and per-user quotas.
- HTTP
trust proxyconfiguration and security headers (separate issue), though the IP derivation helper should be reusable there. - DDoS protection at the network edge; document that a CDN/WAF is still expected in production.
- Outbound throttling and slow consumers (separate coalescing issue).
Proposed implementation
- Config in
src/config/index.tsand.env.example:
SOCKET_MAX_CONNECTIONS(global per instance, default5000),SOCKET_MAX_PER_IP(default20),SOCKET_CONNECT_RATE_PER_MIN(default60),SOCKET_MAX_BUFFER_BYTES(default16384),SOCKET_TRANSPORTS(websocket,pollingdefault;websocketto disable polling),SOCKET_INBOUND_EVENTS_PER_10S(default20),SOCKET_LOG_SAMPLE_RATE(default0.01),TRUSTED_PROXIES(comma list of CIDRs, default empty),SOCKET_IP_EXEMPT_CIDRS(default empty, for known NAT ranges or monitoring). - New
src/lib/socket-limits.ts:Move the origin callback fromexport function clientIp(req: IncomingMessage, trusted: string[]): string; // walks X-Forwarded-For right-to-left skipping trusted proxies export function isAllowedOrigin(origin: string | undefined): boolean; // shared with app.ts CORS export interface ConnectionLimiter { acquire(ip: string, socketId: string): Promise<{ ok: true } | { ok: false; reason: 'GLOBAL' | 'PER_IP' | 'RATE' }>; release(ip: string, socketId: string): Promise<void> } export function redisConnectionLimiter(redis: Redis, opts): ConnectionLimiter; export function memoryConnectionLimiter(opts): ConnectionLimiter; export function inboundEventLimiter(socket: Socket, opts): void; // token bucket per socket export function socketMetrics(): { connected: number; rejected: Record<string, number>; disconnectedForAbuse: number };
src/app.tsintoisAllowedOrigin()and use it in both places. allowRequeston the engine (runs for polling handshakes and websocket upgrades before a socket exists):Requests without anconst io = new Server(server, { transports: config.SOCKET_TRANSPORTS, maxHttpBufferSize: config.SOCKET_MAX_BUFFER_BYTES, pingInterval: 25_000, pingTimeout: 20_000, connectTimeout: 10_000, perMessageDeflate: false, cors: { origin: (o, cb) => cb(null, isAllowedOrigin(o)), methods: ['GET', 'POST'], credentials: true }, allowRequest: (req, cb) => { if (req.headers.origin && !isAllowedOrigin(req.headers.origin)) return cb('ORIGIN_NOT_ALLOWED', false); cb(null, true); }, });
Origin(non-browser clients) are allowed, consistent with HTTP, but still subject to connection caps.- Connection caps in an
io.usemiddleware (async, has the socket id): computeip = clientIp(socket.request, TRUSTED_PROXIES), calllimiter.acquire(ip, socket.id); on rejectionnext(Object.assign(new Error('CONNECTION_LIMIT'), { data: { reason } })). Release ondisconnect. Redis implementation:- Per-IP set:
SADD socklim:ip:<ip> <instanceId>:<socketId>withEXPIRErefreshed by a heartbeat;SCARDcompared to the cap, done atomically in a Lua script that also checks the global counter. - Global per-cluster: a hash
socklim:instancesofinstanceId → count, refreshed every 10 s with a TTL so a crashed instance's counts expire instead of leaking forever. - Connection rate:
INCR socklim:rate:<ip>:<minute>withEXPIRE 120.
If Redis errors, fall back to the in-memory limiter with the same caps (fail closed on global per-instance limits, fail open on cluster-wide counts) and log once per minute.
- Per-IP set:
- Inbound events:
inboundEventLimiterusessocket.use(([event], next) => ...)to reject any event not inALLOWED_INBOUND_EVENTS(initially empty; wave Feature/integration and fixes #4 addssubscribe/unsubscribe) and to enforce a token bucket; after 3 violations in a minute,socket.disconnect(true)and countdisconnectedForAbuse. - Logging: replace the per-connection
logger.infowithlogger.debugplus sampledinfo(Math.random() < SOCKET_LOG_SAMPLE_RATE), always log rejections atwarnbut rate-limited per reason (one line per reason per 10 s with a count). Never log full headers. - Metrics:
socketMetrics()updated by the middleware and engine events (io.engine.on('connection_error', ...)for handshake failures). Expose it on the existing/healthresponse underdata.socketsonly whenNODE_ENV !== 'production', and leave the export for the Prometheus issue. - Graceful shutdown: in the
shutdownhandler insrc/server.ts, callio.close()beforeserver.close(), and release all Redis slots for this instance (delete its entry insocklim:instances). - Docs: add a "Realtime limits" subsection to
docs/api.md(error codes clients may receive on connect:ORIGIN_NOT_ALLOWED,CONNECTION_LIMITwithreason) and a deployment note indocs/architecture.mdaboutTRUSTED_PROXIESand NAT exemptions.
Edge cases & pitfalls
- Spoofed
X-Forwarded-For: only honour it when the immediate peer (req.socket.remoteAddress) is inTRUSTED_PROXIES; otherwise use the peer address. IPv4-mapped IPv6 (::ffff:1.2.3.4) must normalise to IPv4. - Users behind CGNAT or a corporate proxy share an IP;
SOCKET_MAX_PER_IP = 20must be generous enough for many tabs, andSOCKET_IP_EXEMPT_CIDRSgives operators an escape hatch. Consider keying authenticated sockets by user once socket auth exists (note as follow-up). - Polling sessions create several HTTP requests per socket; caps must count sockets, not requests, which is why they live in
io.userather thanallowRequest. - A crashed instance leaves set members in Redis; the per-member heartbeat TTL must reclaim them within a minute.
- Test environments: express-rate-limit is skipped under
NODE_ENV=test, but these limits must be testable; inject options explicitly in tests instead of reading config.
Testing plan
New tests/socket-limits.test.ts (in-process http.Server + Server on port 0, socket.io-client added to devDependencies, memoryConnectionLimiter by default and the Redis limiter when REDIS_URL is reachable):
clientIp ignores X-Forwarded-For from untrusted peers and honours trusted proxieswebsocket upgrade from a disallowed origin is rejectedconnections without Origin are accepted and counted21st connection from one IP is rejected with CONNECTION_LIMIT PER_IPglobal cap rejects new connections and recovers after disconnectsconnection rate limit rejects rapid reconnect loopsmessages larger than maxHttpBufferSize close the connectionunknown inbound events are rejected and repeated violations disconnect the sockettwo server instances sharing Redis enforce one per-IP cap(Redis-gated)crashed instance slots expire after the heartbeat TTL(Redis-gated, fake time via short TTL)
Commands: docker compose up -d redis, npm test, npm run build.
Acceptance criteria
- The socket.io server is created with explicit transport, buffer, ping, timeout and compression options from validated config.
- Origin validation for sockets and HTTP uses one shared
isAllowedOrigin()function, including websocket upgrades. - Per-IP, global and connection-rate limits are enforced in a socket middleware and released on disconnect.
- Limits are shared across instances through Redis with crash-safe expiry, and fall back to in-memory limits when Redis is down.
- Client IP derivation honours only
TRUSTED_PROXIESand normalises IPv6-mapped addresses. - Inbound events are allowlisted and rate-limited per socket.
- Connection logging is sampled and rejection logs are rate-limited; counters are exposed for metrics.
- Adversarial tests pass locally and in CI (Redis-backed tests run in CI's Redis service).
-
.env.example,docs/api.mdanddocs/architecture.mdare updated. -
npm run build,npm testand CI pass.
Resources
src/server.ts,src/app.ts,src/middlewares/rate-limit.middleware.ts,src/lib/redis.ts,src/lib/logger.ts,src/config/index.ts,.env.exampledocs/wave-issues/04-socket-market-rooms.md,docs/wave-issues/07-rate-limit-tuning.md- socket.io server options (
allowRequest,maxHttpBufferSize,transports): https://socket.io/docs/v4/server-options/ - socket.io middlewares: https://socket.io/docs/v4/middlewares/
- Express behind proxies (
trust proxy): https://expressjs.com/en/guide/behind-proxies.html - OWASP WebSocket Security Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/WebSocket_Security_Cheat_Sheet.html
Important
Raise your pull request against the develop branch, not main.
All contributor work for this issue must be submitted as a PR into develop. Pull requests opened against main will not be merged and are closed automatically. Create your branch from develop, and before opening the PR double-check that the base branch is set to develop.
Generated by Claude Code
Activity
harmondconsultant-png commentedon Oct 17, 2026
@harmondconsultant-png has applied to work on this issue as part of the Stellar Wave Program's 10th wave.
ℹ️ Repo Maintainers: To accept this application, review their application or assign @harmondconsultant-png to this issue.
Sirbeejay16 commentedon Oct 17, 2026
@Sirbeejay16 has applied to work on this issue as part of the Stellar Wave Program's 10th wave.
ℹ️ Repo Maintainers: To accept this application, review their application or assign @Sirbeejay16 to this issue.
tijanib141 commentedon Oct 17, 2026
@tijanib141 has applied to work on this issue as part of the Stellar Wave Program's 10th wave.
ℹ️ Repo Maintainers: To accept this application, review their application or assign @tijanib141 to this issue.
Phoniexeagle commentedon Oct 17, 2026
@Phoniexeagle has applied to work on this issue as part of the Stellar Wave Program's 10th wave.
ℹ️ Repo Maintainers: To accept this application, review their application or assign @Phoniexeagle to this issue.
rubbathabrahband commentedon Oct 17, 2026
@rubbathabrahband has applied to work on this issue as part of the Stellar Wave Program's 10th wave.
ℹ️ Repo Maintainers: To accept this application, review their application or assign @rubbathabrahband to this issue.
drips-wave commentedon Oct 17, 2026
Congratulations, @rubbathabrahband! 🎉 Your application was accepted by the repo's maintainers, and the issue is due on October 22, 2026.
🧑💻 @rubbathabrahband: Please resolve the issue such that the repo's maintainers have enough time to review your contribution before the due date. You'll earn Points for completing the issue on-time, which will make you eligible for a share of the Stellar Wave Program's reward pool.
Warning
When opening a PR, please link it to this issue to ensure it gets tracked accurately. Points are awarded when this issue is marked as completed by the maintainer.
🤠 Repo maintainers: Please keep an eye on the contributor's progress and review their work before the due date. You can manage this issue, including adjusting its complexity and points, here.
🌊 Happy Wave 🌊
rubbathabrahband commentedon Oct 17, 2026
Resolved in PR #174: #174