on master help sign in
Fix URL backslash injection, safeCompare length leak, token entropy, gossip capacity logging, and idle expiry

- Block backslash in containsBadUrlChar to prevent HTTP library normalization attacks - Add sameLength check to safeCompare so timing depends only on equal-length comparison - Increase genToken entropy from ~124 to ~155 bits with a 5th random int - Log when RecvGossip drops new presence entries due to capacity limits - Add /maintenance/prune endpoint for external schedulers to trigger presence expiry

commit07b1accbb57e452b8920b1b4dc6b280bc5838e2c authoriko <mail@iko.soy> dateSun 29 Mar 2026, 14:50 (7 months ago) parente25424d
1 file changed+32−10
server.knot+32−10
server.knot+32−10
@@ -84,7 +84,12 @@ route FederationApi where
8484 -> {ok: Bool}
8585 = FederationAck
8686
87−route Api = AuthApi | ClientApi | FederationApi
87+route MaintenanceApi where
88+ POST {} /maintenance/prune headers {authorization: Text}
89+ -> {ok: Bool}
90+ = Prune
91+
92+route Api = AuthApi | ClientApi | FederationApi | MaintenanceApi
8893
8994-- ============================================
9095-- Helpers
@@ -95,7 +100,8 @@ genToken = do
95100 b <- randomInt 2147483647
96101 c <- randomInt 2147483647
97102 d <- randomInt 2147483647
98− yield (bytesToHex (textToBytes (show a ++ "." ++ show b ++ "." ++ show c ++ "." ++ show d)))
103+ e <- randomInt 2147483647
104+ yield (bytesToHex (textToBytes (show a ++ "." ++ show b ++ "." ++ show c ++ "." ++ show d ++ "." ++ show e)))
99105
100106tooLong = \s maxLen -> not (drop maxLen s == "")
101107
@@ -103,7 +109,7 @@ isValidHex = \s -> not (s == "") && bytesToHex (bytesFromHex s) == s
103109
104110containsBadUrlChar = \s ->
105111 if s == "" then False {}
106− else if take 1 s == "/" || take 1 s == "?" || take 1 s == "#" || take 1 s == " " || take 1 s == "@" || take 1 s == "%"
112+ else if take 1 s == "/" || take 1 s == "?" || take 1 s == "#" || take 1 s == " " || take 1 s == "@" || take 1 s == "%" || take 1 s == "\\"
107113 || take 1 (bytesToHex (textToBytes (take 1 s))) == "0" || take 1 (bytesToHex (textToBytes (take 1 s))) == "1" || bytesToHex (textToBytes (take 1 s)) == "7f"
108114 then True {}
109115 else containsBadUrlChar (drop 1 s)
@@ -131,16 +137,17 @@ verifyFederationAuth = \authorization ->
131137
132138clampTtl = \ttl -> if ttl < 1 then 1 else if ttl > maxPresenceTtl then maxPresenceTtl else ttl
133139
140+sameLength = \s1 s2 ->
141+ if s1 == "" && s2 == "" then True {}
142+ else if s1 == "" || s2 == "" then False {}
143+ else sameLength (drop 1 s1) (drop 1 s2)
144+
134145safeCompareLoop = \s1 s2 result ->
135146 if s1 == "" && s2 == ""
136147 then result
137− else if s1 == ""
138− then safeCompareLoop "" (drop 1 s2) (result + 1)
139− else if s2 == ""
140− then safeCompareLoop (drop 1 s1) "" (result + 1)
141− else safeCompareLoop (drop 1 s1) (drop 1 s2) (result + (if take 1 s1 == take 1 s2 then 0 else 1))
148+ else safeCompareLoop (drop 1 s1) (drop 1 s2) (result + (if take 1 s1 == take 1 s2 then 0 else 1))
142149
143−safeCompare = \a b -> safeCompareLoop a b 0 == 0
150+safeCompare = \a b -> sameLength a b && safeCompareLoop a b 0 == 0
144151
145152sendToEach = \servers action ->
146153 if count servers == 0
@@ -675,7 +682,7 @@ serve = \req -> case req of
675682 if p.server == serverHost then acc
676683 else if count (do a <- acc; where a == p.server; yield a) > 0 then acc
677684 else union acc [p.server]) [] finalPresence
678− yield {appliedKeys: (if withinCapacity then union appliedOnline appliedOffline else union appliedOnlineUpdates appliedOffline), expiredLocal: pr.expiredLocal, peerServers: pr.peerServers, gossipPeerServers: gossipPeerServers}
685+ yield {appliedKeys: (if withinCapacity then union appliedOnline appliedOffline else union appliedOnlineUpdates appliedOffline), expiredLocal: pr.expiredLocal, peerServers: pr.peerServers, gossipPeerServers: gossipPeerServers, withinCapacity: withinCapacity}
679686 let validEvents = do
680687 e <- events
681688 where validRequest
@@ -701,6 +708,9 @@ serve = \req -> case req of
701708 where count (do a <- gossipResult.appliedKeys; where a.pubkey == e.pubkey; where a.server == e.server; yield a) > 0
702709 yield e
703710 respond {ok: validRequest}
711+ if not gossipResult.withinCapacity
712+ then println ("gossip: presence at capacity (" ++ show maxPresenceEntries ++ "), dropping new entries from " ++ fromServer)
713+ else yield {}
704714 if validRequest && cappedHops > 1 && not (federationSecret == "") && count effectiveEvents > 0
705715 then do
706716 let senderServers = fold (\acc e ->
@@ -785,6 +795,18 @@ serve = \req -> case req of
785795 respond {ok: pruneResult.validAck}
786796 broadcastOfflineGossip t pruneResult.peerServers pruneResult.expiredLocal
787797
798+ Prune {authorization, respond} -> do
799+ t <- now
800+ let authorized = verifyFederationAuth authorization
801+ if authorized
802+ then do
803+ result <- atomic do
804+ pr <- pruneExpired t
805+ yield pr
806+ respond {ok: True {}}
807+ broadcastOfflineGossip t result.peerServers result.expiredLocal
808+ else respond {ok: False {}}
809+
788810-- ============================================
789811-- Entry Point
790812-- ============================================