From 824204853067fe130d782681d204d54c09b491c5 Mon Sep 17 00:00:00 2001 From: EthanY33 Date: Thu, 7 May 2026 18:12:05 -0400 Subject: [PATCH] sec: add per-conn publish rate limit alongside per-key bucket MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #11. The existing per-API-key bucket (rateLimit) is shared across every connection a key owns. That's correct for fairness across the key's clients, but it doesn't bound what a single socket can push at the moment — a key with N conns sees the bucket drain N× faster but each individual socket is still capped only by the 64KB read limit and the network round-trip. Adds a per-conn rate.Limiter (golang.org/x/time/rate, already a dep via prometheus client). Default 50 publishes/sec sustained, burst 100 — generous for legitimate UI clients (chat apps see far less) and well below what's needed to amplify a 64KB message into a broadcast DoS at the per-channel cap of 10000 subscribers. handlePublish now checks per-key first (existing behavior), then per-conn. RATE_LIMITED stays for per-key over-budget; new RATE_LIMITED_CONN distinguishes the per-conn case so a chatty client can attribute the slowdown. Tunable via defaultConnPublishRate / defaultConnPublishBurst constants in conn.go; flag wiring deferred to whichever PR ends up landing the broader --publish-rate / --publish-burst flags. --- internal/conn/conn.go | 18 +++++++++++++++--- internal/conn/handler.go | 6 +++++- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/internal/conn/conn.go b/internal/conn/conn.go index 1428b7f..5574e46 100644 --- a/internal/conn/conn.go +++ b/internal/conn/conn.go @@ -15,6 +15,7 @@ import ( "github.com/EthanY33/wirefan/internal/ratelimit" "github.com/EthanY33/wirefan/internal/registry" "github.com/coder/websocket" + "golang.org/x/time/rate" ) const ( @@ -37,7 +38,8 @@ type Conn struct { registry registry.Registry signingSecret string fanout fanout.Fanout - rateLimit *ratelimit.Limiter + rateLimit *ratelimit.Limiter // per-API-key bucket; shared across all conns owned by the key + connRate *rate.Limiter // per-conn bucket; bounds a single socket's throughput policy Policy closeReq chan struct{} subs map[string]*registry.Channel @@ -48,8 +50,17 @@ type Conn struct { // Spec'd resource limits. Hardcoded until flag wiring lands. const ( - defaultMaxChannelsPerConn = 64 - defaultMaxSubsPerChannel = 10000 + defaultMaxChannelsPerConn = 64 + defaultMaxSubsPerChannel = 10000 + + // Per-conn publish rate. The per-API-key bucket is shared across all + // conns owned by a key; this layer bounds what a single socket can push, + // independently of how many other conns the key has open. 50/s with a + // burst of 100 is generous for legitimate UI clients (a chat app sees + // far less) and well below what's needed to amplify a 64KB message into + // a meaningful broadcast DoS at 10k subscribers. + defaultConnPublishRate = 50 + defaultConnPublishBurst = 100 ) // CloseFrame implements the hub.closer interface — used by Hub.Drain to broadcast @@ -69,6 +80,7 @@ func Run(ctx context.Context, ws *websocket.Conn, socketID, apiKeyID string, reg signingSecret: signingSecret, fanout: fan, rateLimit: rl, + connRate: rate.NewLimiter(rate.Limit(defaultConnPublishRate), defaultConnPublishBurst), policy: pol, closeReq: make(chan struct{}, 1), subs: map[string]*registry.Channel{}, diff --git a/internal/conn/handler.go b/internal/conn/handler.go index 6501427..018e3c6 100644 --- a/internal/conn/handler.go +++ b/internal/conn/handler.go @@ -81,7 +81,11 @@ func (c *Conn) handlePublish(msg incoming) { return } if !c.rateLimit.Allow(c.apiKeyID) { - c.sendError("RATE_LIMITED", "too many publishes") + c.sendError("RATE_LIMITED", "too many publishes for this API key") + return + } + if !c.connRate.Allow() { + c.sendError("RATE_LIMITED_CONN", "too many publishes on this connection") return } id := ulid.Make().String()