mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-09-17 04:04:22 +00:00
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. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
275 lines
8.9 KiB
Go
275 lines
8.9 KiB
Go
package main
|
|
|
|
// Issue #1794: per-IP limits and a deny list for the /ws upgrade, as defence
|
|
// in depth behind the CheckOrigin allowlist from #1793.
|
|
//
|
|
// CheckOrigin only stops browsers. A Go, Python or curl client can omit the
|
|
// Origin header entirely or forge one, connect, and sit in the hub. These
|
|
// limits work on the transport instead: who is connecting, how often, and how
|
|
// many at once.
|
|
//
|
|
// THE THING THAT MAKES THIS DANGEROUS TO SHIP NAIVELY, stated up front:
|
|
// most CoreScope installs run behind nginx, Caddy, Traefik or a k8s 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
|
|
// address protects nobody; it counts the whole internet as one client and
|
|
// hands the sixth legitimate browser tab a 403. That is a self-inflicted
|
|
// outage, not hardening.
|
|
//
|
|
// So enforcement is conditional on being able to tell clients apart:
|
|
// - X-Forwarded-For is honoured ONLY when the request arrives from an
|
|
// address the operator listed in trustedProxies. Otherwise it is an
|
|
// attacker-supplied header, and trusting it would let anyone forge a
|
|
// fresh source IP per connection, which is worse than no limit at all.
|
|
// - If the peer looks like a local reverse proxy and no trustedProxies is
|
|
// configured, clients cannot be distinguished, so the per-IP limits are
|
|
// SKIPPED and one warning is logged telling the operator what to set.
|
|
// The deny list still applies, because an explicit block is the
|
|
// operator's own instruction rather than an inference.
|
|
//
|
|
// Not shipped: a default deny list of hosting-provider ranges. It was
|
|
// proposed on the issue (44 CIDRs for one VPS host, after a single 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.
|
|
|
|
import (
|
|
"log"
|
|
"net"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
)
|
|
|
|
// wsUpgradeWindow is the span the upgrade budget is counted over. One minute
|
|
// matches the config field name (upgradesPerMinPerIP) so an operator reading
|
|
// the config can predict the behaviour without reading this file.
|
|
const wsUpgradeWindow = time.Minute
|
|
|
|
// wsBucketIdleGC is how long an idle per-IP record is kept before collection.
|
|
// Longer than the window, so a client that pauses briefly cannot earn a fresh
|
|
// budget by being forgotten.
|
|
const wsBucketIdleGC = 10 * time.Minute
|
|
|
|
// wsRejectReason is the label used both in the log line and in the
|
|
// /api/stats counters, so an operator can match one to the other.
|
|
type wsRejectReason string
|
|
|
|
const (
|
|
wsRejectDeny wsRejectReason = "deny"
|
|
wsRejectRate wsRejectReason = "rate"
|
|
wsRejectConnCap wsRejectReason = "conncap"
|
|
)
|
|
|
|
type wsIPState struct {
|
|
conns int
|
|
upgrades []time.Time // timestamps inside the current window
|
|
lastSeenAt time.Time
|
|
}
|
|
|
|
// wsLimiter holds the parsed configuration and the live per-IP state. A nil
|
|
// *wsLimiter allows everything, so a server that never configures limits
|
|
// behaves exactly as it did before this change.
|
|
type wsLimiter struct {
|
|
mu sync.Mutex
|
|
|
|
maxConnsPerIP int // 0 disables the concurrent-connection cap
|
|
upgradesPerMin int // 0 disables the upgrade-rate limit
|
|
|
|
trustedProxies []*net.IPNet
|
|
denyNets []*net.IPNet
|
|
|
|
state map[string]*wsIPState
|
|
|
|
rejectedDeny atomic.Int64
|
|
rejectedRate atomic.Int64
|
|
rejectedConnCap atomic.Int64
|
|
|
|
// warnedIndistinct fires the "set trustedProxies" warning once, rather
|
|
// than on every upgrade from behind an unconfigured proxy.
|
|
warnedIndistinct sync.Once
|
|
}
|
|
|
|
func newWSLimiter() *wsLimiter {
|
|
return &wsLimiter{state: make(map[string]*wsIPState)}
|
|
}
|
|
|
|
// parseCIDRList turns operator strings into networks. A bare address is
|
|
// accepted and treated as a single-host network, because "1.2.3.4" is what an
|
|
// operator naturally writes and silently ignoring it would be the worst
|
|
// possible failure mode for a deny list. An unparseable entry is logged and
|
|
// skipped rather than aborting startup: one typo must not take the server
|
|
// down, but it must not pass unnoticed either.
|
|
func parseCIDRList(entries []string, what string) []*net.IPNet {
|
|
out := make([]*net.IPNet, 0, len(entries))
|
|
for _, raw := range entries {
|
|
e := strings.TrimSpace(raw)
|
|
if e == "" {
|
|
continue
|
|
}
|
|
if _, n, err := net.ParseCIDR(e); err == nil {
|
|
out = append(out, n)
|
|
continue
|
|
}
|
|
if ip := net.ParseIP(e); ip != nil {
|
|
bits := 32
|
|
if ip.To4() == nil {
|
|
bits = 128
|
|
}
|
|
out = append(out, &net.IPNet{IP: ip, Mask: net.CIDRMask(bits, bits)})
|
|
continue
|
|
}
|
|
log.Printf("[ws] WARNING: %s entry %q is neither an IP nor a CIDR, ignored", what, e)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func ipInAny(ip net.IP, nets []*net.IPNet) bool {
|
|
if ip == nil {
|
|
return false
|
|
}
|
|
for _, n := range nets {
|
|
if n.Contains(ip) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// clientIP resolves the address the limits are counted against, and reports
|
|
// whether that address actually identifies a client.
|
|
//
|
|
// distinct is false when the peer is a loopback or private address and no
|
|
// trustedProxies is configured: the request has almost certainly crossed a
|
|
// reverse proxy whose X-Forwarded-For we are not allowed to believe, so every
|
|
// visitor looks identical and per-IP limits would punish the wrong people.
|
|
func (l *wsLimiter) clientIP(r *http.Request) (ip net.IP, distinct bool) {
|
|
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
|
if err != nil {
|
|
host = r.RemoteAddr
|
|
}
|
|
peer := net.ParseIP(strings.TrimSpace(host))
|
|
if peer == nil {
|
|
return nil, false
|
|
}
|
|
if ipInAny(peer, l.trustedProxies) {
|
|
// First hop is the client; later hops were appended by intermediaries
|
|
// we have no reason to trust individually.
|
|
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
|
first := strings.TrimSpace(strings.Split(xff, ",")[0])
|
|
if fwd := net.ParseIP(first); fwd != nil {
|
|
return fwd, true
|
|
}
|
|
}
|
|
// A trusted proxy that forwarded no XFF still identifies itself.
|
|
return peer, true
|
|
}
|
|
if peer.IsLoopback() || peer.IsPrivate() || peer.IsLinkLocalUnicast() {
|
|
return peer, false
|
|
}
|
|
return peer, true
|
|
}
|
|
|
|
// allow decides whether an upgrade may proceed. On rejection it returns the
|
|
// reason. On success it returns a release func the caller MUST invoke when
|
|
// the connection ends, so the concurrent-connection count comes back down.
|
|
func (l *wsLimiter) allow(r *http.Request) (ok bool, reason wsRejectReason, key string, release func()) {
|
|
noop := func() {}
|
|
if l == nil {
|
|
return true, "", "", noop
|
|
}
|
|
ip, distinct := l.clientIP(r)
|
|
|
|
// The deny list is an explicit operator instruction, so it applies even
|
|
// when clients cannot be told apart. Behind an unconfigured proxy it
|
|
// simply never matches, which is visible rather than silently wrong.
|
|
if ipInAny(ip, l.denyNets) {
|
|
l.rejectedDeny.Add(1)
|
|
return false, wsRejectDeny, ip.String(), noop
|
|
}
|
|
if l.maxConnsPerIP <= 0 && l.upgradesPerMin <= 0 {
|
|
return true, "", "", noop
|
|
}
|
|
if !distinct {
|
|
l.warnedIndistinct.Do(func() {
|
|
log.Printf("[ws] WARNING: per-IP limits are configured but every request arrives from %s, "+
|
|
"so clients cannot be told apart and the limits are NOT enforced. "+
|
|
"Set webSocket.trustedProxies to your reverse proxy's address to enable them.", ip)
|
|
})
|
|
return true, "", "", noop
|
|
}
|
|
|
|
key = ip.String()
|
|
now := time.Now()
|
|
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
l.gcLocked(now)
|
|
|
|
st := l.state[key]
|
|
if st == nil {
|
|
st = &wsIPState{}
|
|
l.state[key] = st
|
|
}
|
|
st.lastSeenAt = now
|
|
|
|
if l.upgradesPerMin > 0 {
|
|
cutoff := now.Add(-wsUpgradeWindow)
|
|
kept := st.upgrades[:0]
|
|
for _, t := range st.upgrades {
|
|
if t.After(cutoff) {
|
|
kept = append(kept, t)
|
|
}
|
|
}
|
|
st.upgrades = kept
|
|
if len(st.upgrades) >= l.upgradesPerMin {
|
|
l.rejectedRate.Add(1)
|
|
return false, wsRejectRate, key, noop
|
|
}
|
|
}
|
|
if l.maxConnsPerIP > 0 && st.conns >= l.maxConnsPerIP {
|
|
l.rejectedConnCap.Add(1)
|
|
return false, wsRejectConnCap, key, noop
|
|
}
|
|
|
|
// Both budgets have room, so charge them together: a rejected upgrade
|
|
// must never consume rate budget for a connection it did not get.
|
|
if l.upgradesPerMin > 0 {
|
|
st.upgrades = append(st.upgrades, now)
|
|
}
|
|
st.conns++
|
|
|
|
var once sync.Once
|
|
return true, "", key, func() {
|
|
once.Do(func() {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
if s := l.state[key]; s != nil && s.conns > 0 {
|
|
s.conns--
|
|
s.lastSeenAt = time.Now()
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// gcLocked drops records with no live connections and no recent activity, so
|
|
// the map cannot grow without bound when the server is scanned from many
|
|
// addresses. Caller must hold l.mu.
|
|
func (l *wsLimiter) gcLocked(now time.Time) {
|
|
for k, st := range l.state {
|
|
if st.conns == 0 && now.Sub(st.lastSeenAt) > wsBucketIdleGC {
|
|
delete(l.state, k)
|
|
}
|
|
}
|
|
}
|
|
|
|
// counts reports the rejection tallies for /api/stats.
|
|
func (l *wsLimiter) counts() (deny, rate, connCap int64) {
|
|
if l == nil {
|
|
return 0, 0, 0
|
|
}
|
|
return l.rejectedDeny.Load(), l.rejectedRate.Load(), l.rejectedConnCap.Load()
|
|
}
|