on master help sign in
Fix bug #12: key IP rate-limit buckets on X-Forwarded-For behind a proxy

byClientIp keyed on the TCP connection address, so under the install.sh deployment (Caddy in front) every peer bucketed under the proxy's loopback IP and the gossip/forward limits were effectively global. Use the last X-Forwarded-For hop — the entry the nearest trusted proxy appends — gated by a new overridable trustForwardedFor constant (default True to match install.sh; set False for direct deployments where the header is client-controlled).

commit23efe28658546ee1935b0d899e7c999881f6110e authoriko <mail@iko.soy> dateThu 11 Jun 2026, 13:56 (4 months ago) parentdbd7f47
1 file changed+29−2
server.knot+29−2
server.knot+29−2
@@ -135,6 +135,14 @@ gossipRateLimit = 60
135135-- gossip because peers fan out one call per buffered ciphertext.
136136forwardRateLimit : Int
137137forwardRateLimit = 600
138+-- Whether rate limiting may trust X-Forwarded-For for the client address.
139+-- True matches the install.sh deployment (Caddy in front, which appends the
140+-- real client address as the header's final entry — the only entry a trusted
141+-- proxy vouches for). Set --trustForwardedFor=False when clients connect
142+-- directly, otherwise a client could spoof the header to mint fresh
143+-- rate-limit buckets.
144+trustForwardedFor : Bool
145+trustForwardedFor = True {}
138146
139147-- Master switch for federation; when False the server runs in single-node mode.
140148federationEnabled : Bool
@@ -260,14 +268,33 @@ type ForwardFailure = {server: Text, failedAt: Timestamp, failures: Int}
260268-- Per-endpoint token-bucket configuration. Auth endpoints key on the submitted
261269-- pubkey (so one keypair can't drain global capacity); authenticated client
262270-- endpoints key on the bearer token (per-session); federation endpoints key on
263−-- the connection IP. Each route has an independent bucket store, so endpoints
271+-- the client IP (last X-Forwarded-For hop behind a trusted proxy, else the
272+-- connection address). Each route has an independent bucket store, so endpoints
264273-- using the same key strategy don't share budget across routes. Buckets persist
265274-- in the runtime's `_knot_rate_limits` SQLite table; rejection responds 429
266275-- before the handler runs.
267276
277+-- Suffix of `s` after its last comma, trimmed. X-Forwarded-For is a
278+-- comma-separated chain that each proxy appends to; only the final entry —
279+-- the one written by the nearest trusted proxy — is reliable for bucketing.
280+lastForwardedHop : Text -> Text
281+lastForwardedHop = \s -> if contains "," s
282+then lastForwardedHop (drop 1 s)
283+else trim s
284+
285+-- The client address for rate-limit bucketing: the last X-Forwarded-For hop
286+-- when `trustForwardedFor` is set (and the header is present and non-empty),
287+-- otherwise the TCP connection address.
288+pickClientIp : Maybe Text -> Text -> Text
289+pickClientIp = \h connIp -> case h of
290+ Just {value: ips} -> if trustForwardedFor && lastForwardedHop ips != ""
291+ then lastForwardedHop ips
292+ else connIp
293+ Nothing {} -> connIp
294+
268295byPubkey = \{pubkey} _ -> Just {value: pubkey}
269296byAuth = \{authorization} _ -> Just {value: authorization}
270−byClientIp = \_ ctx -> Just {value: ctx.clientIp}
297+byClientIp = \_ ctx -> Just {value: pickClientIp (ctx.header "X-Forwarded-For") ctx.clientIp}
271298
272299authLimit = {key: byPubkey, limit: {requests: authRateLimit, window: rateLimitWindow}}
273300pollLimit = {key: byAuth, limit: {requests: pollRateLimit, window: rateLimitWindow}}