Skip to content

feat(#1794): per-IP limits and a deny list on the /ws upgrade - #1974

Merged
efiten merged 1 commit into
masterfrom
feat/1794-ws-limits
Sep 6, 2026
Merged

feat(#1794): per-IP limits and a deny list on the /ws upgrade#1974
efiten merged 1 commit into
masterfrom
feat/1794-ws-limits

Conversation

@efiten

@efiten efiten commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator

Closes #1794. Follow-up to #1793, decided before the upgrade because the handshake is the resource being protected.

  • Deny list of addresses and CIDRs → 403
  • Per-IP concurrent connection cap → 403
  • Per-IP upgrade rate limit over a rolling minute → 429, not 403: a temporary refusal should not read as "never come back"
  • Rejection counters split by cause in /api/stats under websocket

The decision this feature lives or dies on

Most CoreScope installs sit behind nginx, Caddy, Traefik or an ingress. cdn_detection.go says so in as many words: it deliberately excludes X-Forwarded-For from its CDN signals precisely because every reverse-proxied install sets it. For those deployments r.RemoteAddr is the proxy, 127.0.0.1 for every visitor on earth. A per-IP cap keyed on that address protects nobody and hands the sixth legitimate browser tab a 403. That is a self-inflicted outage wearing the costume of hardening.

So:

  • X-Forwarded-For is believed only from an address listed in webSocket.trustedProxies. From anywhere else it is attacker-supplied, and trusting it would let anyone mint a fresh source IP per connection, which is strictly worse than having no limit at all.
  • When the peer looks like a local reverse proxy and no trustedProxies is set, the per-IP limits are skipped, and one warning names the setting that fixes it. Silently refusing real users is the worse failure.
  • The deny list still applies there, because it is the operator's explicit instruction rather than an inference.

That is the answer to @mcode6726's question on the thread: it is neither "always the socket address" nor "always the header", and the operator decides which by naming their proxy.

Two deliberate departures from the issue body

maxConnsPerIP ships as 0 (off), not 5. Carrier-grade NAT puts thousands of unrelated mobile subscribers behind a single public IPv4. A cap of 5 refuses real visitors on phones while a scraper simply rents more addresses: all of the cost, none of the benefit. upgradesPerMinPerIP ships at 30 and on, because that one is safe under CGNAT: a real client upgrades a handful of times per minute even while reconnecting, so 30 leaves ordinary traffic untouched while flattening a reconnect loop. A pointer type distinguishes "unset" from an explicit 0 that turns it off.

The default deny list is not shipped. The thread proposed seeding 44 CIDRs for one VPS provider after a single scraper was seen at 23.111.177.6. I have left it out: blanket-blocking a hosting provider by default breaks legitimate operators who host there, is undiscoverable by the person locked out (they see a bare 403), and ages badly as ranges get reassigned. The mechanism is here and config.example.json shows exactly how to configure it, so any operator who wants that list can have it in one line. If you want it shipped as a default anyway, that is your call as maintainer and it is a one-line change.

Verification

19 tests, including all five the issue specifies as TDD requirements, each marked with the issue's own wording. Beyond those five:

  • a bare address in the deny list works, not just CIDR form. Operators write 1.2.3.4, and silently ignoring that would be the worst possible failure for a deny list: it looks configured and blocks nothing
  • an unparseable deny entry is skipped and logged, not fatal. One typo must not take the server down
  • one client behind a trusted proxy does not exhaust another client's budget behind the same proxy, which is the entire point of honouring XFF
  • changing a forged XFF from an untrusted peer buys no fresh budget
  • release frees a slot and is idempotent, because Unregister can run twice for one client and double-crediting would leak slots
  • a rejected upgrade does not consume rate budget, or a retrying client could never recover once its window cleared
  • limits skipped for loopback and private peers; deny list applies anyway
  • a nil limiter allows everything, so a Hub built without ConfigureLimits behaves exactly as before
  • idle per-IP state is collected, while a record with a live connection never is

Full cmd/server suite green, gofmt clean.

Not done

  • No runtime config reload; restart required. Listed as optional in the issue.
  • No WS_DENY_IPS env override. Also listed as optional.
  • From the OWASP expansion in the first comment: maxPayload and the idle/read timeout are already in master (SetReadLimit, SetReadDeadline). The ping/pong heartbeat is not, and is not in this PR either; it is a separate change to the read/write pumps and belongs in its own review.

Follow-up to #1793. CheckOrigin only stops browsers: a Go, Python or curl
client can omit Origin entirely or forge one, connect, and sit in the hub.
These limits work on the transport instead, and are decided BEFORE the
upgrade, since the handshake is the resource being protected.

- Deny list of addresses and CIDRs, refused with 403.
- Per-IP concurrent connection cap.
- Per-IP upgrade rate limit over a rolling minute, refused with 429 rather
  than 403: a temporary refusal should not read as "never come back".
- Rejection counters split by cause in /api/stats under websocket.

The part that decides whether this helps or hurts: source IP
-------------------------------------------------------------
Most CoreScope installs run behind nginx, Caddy, Traefik or an ingress;
cdn_detection.go says so in as many words. For those, r.RemoteAddr is the
proxy, 127.0.0.1 for every visitor on earth. A per-IP cap keyed on that
protects nobody and hands the sixth legitimate browser tab a 403.

So X-Forwarded-For is believed ONLY from an address the operator listed in
webSocket.trustedProxies. From anywhere else it is attacker-supplied, and
trusting it would let anyone mint a fresh source IP per connection, which is
worse than having no limit at all. And when the peer looks like a local
reverse proxy with no trustedProxies configured, clients cannot be told apart,
so the per-IP limits are SKIPPED and one warning names the setting to fix it.
The deny list still applies there, because it is an explicit instruction
rather than an inference. This answers @mcode6726's question on the thread.

Defaults, and a deliberate departure from the issue body
--------------------------------------------------------
maxConnsPerIP ships as 0 (off), not 5. Carrier-grade NAT puts thousands of
unrelated mobile subscribers behind one public IPv4, so a cap of 5 refuses
real visitors on phones while a scraper simply rents more addresses.
upgradesPerMinPerIP ships at 30 and on, which is safe under CGNAT: a real
client upgrades a handful of times per minute even while reconnecting.
A pointer type distinguishes "unset" from an explicit 0 that turns it off.

Also not shipped: the default deny list of 44 hosting-provider CIDRs proposed
on the issue after one scraper was seen. Blanket-blocking a provider by
default breaks legitimate operators who host there, is undiscoverable by the
person locked out, and ages badly as ranges are reassigned. Operators who want
it can configure it, and config.example.json shows how.

Tests: 19 cases, including the five the issue specifies. Deny by CIDR and by
bare address (operators write "1.2.3.4", and silently ignoring that form would
be the worst possible failure for a deny list); an unparseable entry skipped
rather than fatal; the 6th connection at cap 5; the 31st upgrade at 30/min;
XFF honoured from a trusted proxy and one client not exhausting another's
budget behind the same proxy; XFF ignored from an untrusted peer, including
that changing the forged header buys nothing; release freeing a slot and being
idempotent, since Unregister can run twice; a rejected upgrade not consuming
rate budget; limits skipped for loopback and private peers; the deny list
applying anyway; a nil limiter allowing everything so a Hub without
ConfigureLimits behaves exactly as before; and idle state collected while a
record with a live connection is never collected.

Full cmd/server suite green, gofmt clean. Not done: no runtime config reload
(restart required), and no env-var override, both listed as optional in the
issue. maxPayload and idle timeout from the OWASP expansion are already in
master via SetReadLimit and SetReadDeadline; ping/pong heartbeat is not, and
is not in this PR.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQS3XLoPD98yu9pxdRujqg
@efiten
efiten merged commit 1ffaad8 into master Sep 6, 2026
7 checks passed
@efiten
efiten deleted the feat/1794-ws-limits branch September 6, 2026 19:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

WebSocket /ws: per-IP rate limit, conn cap, and source-IP deny list (follow-up to #1793)

1 participant