Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions cmd/server/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,10 @@ type Config struct {
// so browsers enforce same-origin policy. Set to ["*"] to allow all origins.
CORSAllowedOrigins []string `json:"corsAllowedOrigins,omitempty"`

// WebSocket carries the /ws transport limits from #1794. Omitted entirely
// means: rate limit at its default, connection cap off, no deny list.
WebSocket *WebSocketConfig `json:"webSocket,omitempty"`

DebugAffinity bool `json:"debugAffinity,omitempty"`

// MapDarkTileProvider selects the default dark-mode basemap provider for
Expand Down Expand Up @@ -619,6 +623,81 @@ func LoadConfig(baseDirs ...string) (*Config, error) {
return cfg, nil // defaults
}

// WebSocketConfig holds the /ws transport limits (#1794). These sit behind
// the CheckOrigin allowlist (#1793) and catch non-browser clients, which can
// omit or forge Origin.
type WebSocketConfig struct {
// MaxConnsPerIP caps concurrent /ws connections from one client address.
// DEFAULT 0, meaning OFF, and that is a deliberate departure from the
// value floated on #1794.
//
// A cap of 5 is safe only when one address means one household. It does
// not on mobile: carrier-grade NAT puts thousands of unrelated subscribers
// behind a single public IPv4, so a low cap would refuse real visitors on
// phones while barely inconveniencing a scraper that can rent more
// addresses. Operators who know their audience can set it; we must not
// pick it for them.
MaxConnsPerIP int `json:"maxConnsPerIP,omitempty"`

// UpgradesPerMinPerIP caps handshakes per minute from one client address.
// DEFAULT 30, on. Unlike the connection cap this 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
// the reconnect loop that makes scrapers expensive.
UpgradesPerMinPerIP *int `json:"upgradesPerMinPerIP,omitempty"`

// TrustedProxies lists the addresses or CIDRs of reverse proxies whose
// X-Forwarded-For may be believed. REQUIRED for the limits to do anything
// behind nginx/Caddy/Traefik/ingress: without it every visitor shares the
// proxy's address, so the limits are skipped and a warning is logged.
// Never list an address you do not control; anyone reaching the server
// from a trusted address can name any client IP they like.
TrustedProxies []string `json:"trustedProxies,omitempty"`

// Deny blocks addresses outright at the upgrade, before the handshake.
// Accepts both bare addresses ("1.2.3.4") and CIDRs ("1.2.3.0/24").
// Applies even when clients cannot be told apart, because it is an
// explicit instruction rather than an inference.
Deny []string `json:"deny,omitempty"`
}

// WSMaxConnsPerIP returns the configured concurrent-connection cap, 0 = off.
func (c *Config) WSMaxConnsPerIP() int {
if c.WebSocket == nil || c.WebSocket.MaxConnsPerIP < 0 {
return 0
}
return c.WebSocket.MaxConnsPerIP
}

// WSUpgradesPerMinPerIP returns the upgrade-rate cap, defaulting to 30 when
// unset. A pointer distinguishes "not configured" (use the default) from an
// explicit 0, which an operator uses to turn the limit off.
func (c *Config) WSUpgradesPerMinPerIP() int {
if c.WebSocket == nil || c.WebSocket.UpgradesPerMinPerIP == nil {
return 30
}
if v := *c.WebSocket.UpgradesPerMinPerIP; v > 0 {
return v
}
return 0
}

// WSTrustedProxies returns the configured reverse-proxy addresses, if any.
func (c *Config) WSTrustedProxies() []string {
if c.WebSocket == nil {
return nil
}
return c.WebSocket.TrustedProxies
}

// WSDeny returns the configured deny entries, if any.
func (c *Config) WSDeny() []string {
if c.WebSocket == nil {
return nil
}
return c.WebSocket.Deny
}

func (c *Config) applyListLimitsDefaults() {
if c.ListLimits == nil {
c.ListLimits = &ListLimitsConfig{}
Expand Down
2 changes: 2 additions & 0 deletions cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,8 @@ func main() {
// WebSocket hub
hub := NewHub()
hub.SetAllowedOrigins(cfg.CORSAllowedOrigins)
hub.ConfigureLimits(cfg.WSMaxConnsPerIP(), cfg.WSUpgradesPerMinPerIP(),
cfg.WSTrustedProxies(), cfg.WSDeny()) // #1794
hub.upgrader.EnableCompression = cfg.WSCompressionEnabled()

// HTTP server
Expand Down
7 changes: 5 additions & 2 deletions cmd/server/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -889,8 +889,10 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
uptime := time.Since(s.startedAt).Seconds()

wsClients := 0
var wsDeny, wsRate, wsConnCap int64
if s.hub != nil {
wsClients = s.hub.ClientCount()
wsDeny, wsRate, wsConnCap = s.hub.limits.counts() // #1794; nil-safe
}

// Real packet store stats
Expand Down Expand Up @@ -962,8 +964,9 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
P95Ms: round(percentile(sortedPauses, 0.95), 1),
P99Ms: round(percentile(sortedPauses, 0.99), 1),
},
Cache: cs,
WebSocket: WebSocketStatsResp{Clients: wsClients},
Cache: cs,
WebSocket: WebSocketStatsResp{Clients: wsClients,
RejectedDeny: wsDeny, RejectedRate: wsRate, RejectedConnCap: wsConnCap},
PacketStore: HealthPacketStoreStats{
Packets: pktCount,
EstimatedMB: pktEstMB,
Expand Down
7 changes: 7 additions & 0 deletions cmd/server/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -586,6 +586,13 @@ type PerfCacheStats struct {

type WebSocketStatsResp struct {
Clients int `json:"clients"`
// #1794: upgrade rejections since boot, split by cause so an operator can
// tell a deny-list hit from a client that is merely reconnecting too fast.
// omitempty: an install with no limits configured sends none of these
// rather than three permanent zeroes.
RejectedDeny int64 `json:"rejectedDeny,omitempty"`
RejectedRate int64 `json:"rejectedRate,omitempty"`
RejectedConnCap int64 `json:"rejectedConnCap,omitempty"`
}

type HealthPacketStoreStats struct {
Expand Down
57 changes: 54 additions & 3 deletions cmd/server/websocket.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ type Hub struct {
mu sync.RWMutex
clients map[*Client]bool
upgrader websocket.Upgrader
allowedOrigins []string // exact-match allowlist for /ws CheckOrigin (see SetAllowedOrigins)
allowedOrigins []string // exact-match allowlist for /ws CheckOrigin (see SetAllowedOrigins)
limits *wsLimiter // #1794: per-IP caps and deny list; nil allows everything
}

// SetAllowedOrigins configures the exact-match origin allowlist consulted by
Expand Down Expand Up @@ -78,6 +79,32 @@ type Client struct {
conn *websocket.Conn
send chan []byte
closeOnce sync.Once
// release returns this connection's slot to the per-IP concurrent cap
// (#1794). Always non-nil so Unregister needs no nil check; it is a no-op
// when limits are disabled.
release func()
}

// ConfigureLimits installs the #1794 transport limits. Called once at
// startup, before the listener binds, so no upgrade can race a half-built
// limiter. Passing zero values for both caps leaves only the deny list
// active, which is a legitimate configuration.
func (h *Hub) ConfigureLimits(maxConnsPerIP, upgradesPerMin int, trustedProxies, deny []string) {
l := newWSLimiter()
l.maxConnsPerIP = maxConnsPerIP
l.upgradesPerMin = upgradesPerMin
l.trustedProxies = parseCIDRList(trustedProxies, "webSocket.trustedProxies")
l.denyNets = parseCIDRList(deny, "webSocket.deny")
h.mu.Lock()
h.limits = l
h.mu.Unlock()
if len(l.denyNets) > 0 {
log.Printf("[ws] deny list active: %d network(s)", len(l.denyNets))
}
if len(l.trustedProxies) == 0 && (maxConnsPerIP > 0 || upgradesPerMin > 0) {
log.Printf("[ws] per-IP limits configured with no trustedProxies: they apply only to " +
"directly-connected clients. Behind a reverse proxy, set webSocket.trustedProxies.")
}
}

func NewHub() *Hub {
Expand Down Expand Up @@ -112,6 +139,12 @@ func (h *Hub) Unregister(c *Client) {
c.closeOnce.Do(func() { close(c.send) })
}
h.mu.Unlock()
// #1794: outside the lock — release takes the limiter's own mutex, and
// holding two locks in one order here and the other order elsewhere is how
// deadlocks are made. Idempotent, so a double Unregister cannot over-credit.
if c.release != nil {
c.release()
}
log.Printf("[ws] client disconnected (%d total)", h.ClientCount())
}

Expand Down Expand Up @@ -151,15 +184,33 @@ func (h *Hub) Broadcast(msg interface{}) {

// ServeWS handles the WebSocket upgrade and runs the client.
func (h *Hub) ServeWS(w http.ResponseWriter, r *http.Request) {
// #1794: decide BEFORE the upgrade. Rejecting afterwards would already
// have allocated the connection and completed the handshake, which is the
// resource this is meant to protect.
ok, reason, key, release := h.limits.allow(r)
if !ok {
status := http.StatusForbidden
if reason == wsRejectRate {
// 429 tells a well-behaved client to back off; 403 would read as
// "never come back" for what is a temporary refusal.
status = http.StatusTooManyRequests
}
log.Printf("[ws] reject ip=%s reason=%s", key, reason)
http.Error(w, "websocket upgrade refused", status)
return
}

conn, err := h.upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("[ws] upgrade error: %v", err)
release() // the slot was charged above and this connection never happened
return
}

client := &Client{
conn: conn,
send: make(chan []byte, 256),
conn: conn,
send: make(chan []byte, 256),
release: release,
}
h.Register(client)

Expand Down
Loading
Loading