on master help sign in
Make X-Forwarded-For header optional; skip IP rate-limiting when absent
commitb77814a4d4279f3aa8de1def48f58e3249855201 authoriko <mail@iko.soy> dateFri 8 May 2026, 15:59 (5 months ago) parentaf6d881
1 file changed+21−18
server.knot+21−18
server.knot+21−18
@@ -158,9 +158,6 @@ federationEnabled : Bool
158158federationEnabled = True {}
159159federationUrl : Text -> Text
160160federationUrl = \server -> federationScheme ++ server
161−-- When True, reject requests that arrive without a client-IP header (for IP-based rate limits).
162−requireClientIp : Bool
163−requireClientIp = True {}
164161
165162-- ============================================
166163-- Types
@@ -307,12 +304,12 @@ type RateEntry = {key: Text, windowStart: Timestamp, count: Int}
307304-- `{"error": "<code>"}`.
308305
309306route AuthApi where
310− POST {pubkey: PubkeyHex} /auth/challenge headers {clientIp: Text} -> {challenge: Text, expiresAt: Timestamp} = ReqChallenge
311− POST {pubkey: PubkeyHex, challenge: Text, signature: SignatureHex, revokeOthers: Bool} /auth/verify headers {clientIp: Text} -> {token: Text, expiresAt: Timestamp} = VerifyAuth
307+ POST {pubkey: PubkeyHex} /auth/challenge headers {xForwardedFor: Maybe Text} -> {challenge: Text, expiresAt: Timestamp} = ReqChallenge
308+ POST {pubkey: PubkeyHex, challenge: Text, signature: SignatureHex, revokeOthers: Bool} /auth/verify headers {xForwardedFor: Maybe Text} -> {token: Text, expiresAt: Timestamp} = VerifyAuth
312309
313310route ClientApi where
314− POST {cursor: Timestamp} /poll headers {authorization: Text, clientIp: Text} -> {events: [PollEvent], cursor: Timestamp} = Poll
315− POST {messages: [{to: PubkeyHex, encryptedBlob: BlobHex}]} /messages headers {authorization: Text, clientIp: Text} -> {} = SendMessages
311+ POST {cursor: Timestamp} /poll headers {authorization: Text, xForwardedFor: Maybe Text} -> {events: [PollEvent], cursor: Timestamp} = Poll
312+ POST {messages: [{to: PubkeyHex, encryptedBlob: BlobHex}]} /messages headers {authorization: Text, xForwardedFor: Maybe Text} -> {} = SendMessages
316313
317314route FederationApi where
318315 POST {events: [GossipEventType], fromServer: ServerName} /federation/gossip -> {} = RecvGossip
@@ -485,6 +482,14 @@ isBadServerName = \sn -> sn == "" || sn == "localhost" || not all (chars sn) (\c
485482matchIp : Text -> Text -> Bool
486483matchIp = \clientIp sessionIp -> sessionIp == "" || clientIp == sessionIp
487484
485+-- Unwrap an X-Forwarded-For header value to a plain IP string. Empty when the
486+-- header is absent (no reverse proxy in front), which downstream gates treat
487+-- as "skip IP-based rate limiting".
488+clientIpOf : Maybe Text -> Text
489+clientIpOf = \h -> case h of
490+ Just {value: ip} -> ip
491+ Nothing {} -> ""
492+
488493-- Pull the token out of an `Authorization: Bearer <token>` header. Returns
489494-- Nothing if the prefix is missing or the token is empty.
490495extractToken : Text -> Maybe Text
@@ -567,7 +572,9 @@ checkAuthGlobalRate = \t -> atomic (do
567572 *authGlobalRateCount = c + 1
568573 yield (False {}))
569574
570−-- Per-source-IP rate gate (skipped when the IP is unknown — see `requireClientIp`).
575+-- Per-source-IP rate gate. When the IP is unknown (no X-Forwarded-For from a
576+-- reverse proxy), the gate is skipped — direct deployments without a proxy
577+-- still rely on global rate limits.
571578checkIpRateGate : Text -> Int<Ms> -> IO {rw *ipRates} Bool
572579checkIpRateGate = \ip t -> if ip == ""
573580then yield (False {})
@@ -816,13 +823,9 @@ refreshLocalPresence = \t -> do
816823handleChallenge : PubkeyHex -> Text -> IO {rw *authGlobalRateCount, rw *authGlobalRateWindowStart, rw *challengeRates, rw *challenges, rw *globalRateCount, rw *globalRateWindowStart, rw *ipRates, clock, random} (Result HttpError {challenge: Text, expiresAt: Int})
817824handleChallenge = \pubkey clientIp -> do
818825 t <- now
819− preErr <- if requireClientIp && clientIp == ""
820− then yield "client_ip_required"
821− else authPreCheck clientIp t
826+ preErr <- authPreCheck clientIp t
822827 if preErr != ""
823− then yield (Err {
824− error: {status: if preErr == "client_ip_required" then 400 else 429, message: preErr}
825− })
828+ then yield (Err {error: {status: 429, message: preErr}})
826829 else do
827830 challenge <- genToken
828831 result <- atomic (do
@@ -1191,10 +1194,10 @@ handleFederationBatchForward = \forwards _ -> do
11911194-- its `handleXxx` function. Every route entry above must have a clause here.
11921195api : Server Api _
11931196api = serve Api where
1194− ReqChallenge = \{pubkey, clientIp} -> handleChallenge pubkey clientIp
1195− VerifyAuth = \{pubkey, challenge, signature, revokeOthers, clientIp} -> handleVerifyAuth pubkey challenge signature revokeOthers clientIp
1196− Poll = \{cursor, authorization, clientIp} -> handlePoll cursor authorization clientIp
1197− SendMessages = \{messages, authorization, clientIp} -> handleSendMessages messages authorization clientIp
1197+ ReqChallenge = \{pubkey, xForwardedFor} -> handleChallenge pubkey (clientIpOf xForwardedFor)
1198+ VerifyAuth = \{pubkey, challenge, signature, revokeOthers, xForwardedFor} -> handleVerifyAuth pubkey challenge signature revokeOthers (clientIpOf xForwardedFor)
1199+ Poll = \{cursor, authorization, xForwardedFor} -> handlePoll cursor authorization (clientIpOf xForwardedFor)
1200+ SendMessages = \{messages, authorization, xForwardedFor} -> handleSendMessages messages authorization (clientIpOf xForwardedFor)
11981201 RecvGossip = \{events, fromServer} -> handleRecvGossip events fromServer
11991202 ForwardMessage = \{toKey, encryptedBlob, fromServer} -> handleForwardMessage toKey encryptedBlob fromServer
12001203 FederationBatchForward = \{forwards, fromServer} -> handleFederationBatchForward forwards fromServer