feat(#1794): per-IP limits and a deny list on the /ws upgrade - #1974
Merged
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #1794. Follow-up to #1793, decided before the upgrade because the handshake is the resource being protected.
/api/statsunderwebsocketThe decision this feature lives or dies on
Most CoreScope installs sit behind nginx, Caddy, Traefik or an ingress.
cdn_detection.gosays so in as many words: it deliberately excludesX-Forwarded-Forfrom its CDN signals precisely because every reverse-proxied install sets it. For those deploymentsr.RemoteAddris the proxy,127.0.0.1for 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-Foris believed only from an address listed inwebSocket.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.trustedProxiesis set, the per-IP limits are skipped, and one warning names the setting that fixes it. Silently refusing real users is the worse failure.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
maxConnsPerIPships 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.upgradesPerMinPerIPships 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 explicit0that 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 andconfig.example.jsonshows 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:
1.2.3.4, and silently ignoring that would be the worst possible failure for a deny list: it looks configured and blocks nothingreleasefrees a slot and is idempotent, becauseUnregistercan run twice for one client and double-crediting would leak slotsHubbuilt withoutConfigureLimitsbehaves exactly as beforeFull
cmd/serversuite green,gofmtclean.Not done
WS_DENY_IPSenv override. Also listed as optional.maxPayloadand 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.