on master help sign in
Fix bug #19: text length checks used count (chars s), which dedupes

Relations are sets — chars returns the distinct character set, so count (chars s) is the distinct-char count, not the string length. That broke nearly every text helper under the current Knot toolchain: - isValidExactHex 64 could never pass (16 distinct hex chars max), so every PubkeyHex/SignatureHex refinement rejected -> all requests 400 - hasPrefix "Bearer " compared take 5 against a 7-char prefix -> all authenticated routes 401 - the 192.168./169.254. SSRF prefixes and all suffix checks never matched - the multi-colon IPv6 check could never see more than one ':' - isValidServerName's length cap was a distinct-char cap Use length for character counts, keep chars only for membership checks, and add countChar for the colon count. Verified with a compiled test covering prefix/suffix/hex/colon/trailing-dot cases.

commitdbd7f471d6b49c34594ba6d41734e6fdbaf59e8d authoriko <mail@iko.soy> dateThu 11 Jun 2026, 13:55 (4 months ago) parenta426743
1 file changed+18−10
server.knot+18−10
server.knot+18−10
@@ -182,10 +182,10 @@ impl FromJSON GossipEventType where
182182
183183-- True when `s` is non-empty lowercase hex of even length, with `maxLen` ceiling.
184184isValidBoundedHex : Int -> Text -> Bool
185−isValidBoundedHex = \maxLen s -> count (chars s) <= maxLen && isValidHex s
185+isValidBoundedHex = \maxLen s -> length s <= maxLen && isValidHex s
186186-- True when `s` is lowercase hex of exactly `exactLen` characters (even by definition).
187187isValidExactHex : Int -> Text -> Bool
188−isValidExactHex = \exactLen s -> count (chars s) == exactLen && isValidHex s
188+isValidExactHex = \exactLen s -> length s == exactLen && isValidHex s
189189-- Hex-encoded Ed25519 public key (32 bytes → 64 chars exact).
190190type PubkeyHex = Text where isValidExactHex 64
191191-- Hex-encoded ciphertext or opaque blob; capped at `maxBlobLen` (~20 MiB).
@@ -198,7 +198,7 @@ type SignatureHex = Text where isValidExactHex 128
198198-- `serverHost = "localhost"` flow through presence rows in a non-federated
199199-- single-node deployment.
200200isValidServerName : Text -> Bool
201−isValidServerName = \s -> count (chars s) <= maxServerNameLen && s == toLower (stripTrailingDot s)
201+isValidServerName = \s -> length s <= maxServerNameLen && s == toLower (stripTrailingDot s)
202202type ServerName = Text where isValidServerName
203203
204204-- Wall-clock time in milliseconds since epoch.
@@ -333,16 +333,17 @@ else do
333333 yield (take 1 (drop d "0123456789abcdef") ++ rest)
334334
335335-- True when `s` is non-empty, even-length, and only lowercase hex digits.
336+-- `chars` yields the *distinct* character set (relations dedupe), which is
337+-- fine for the membership check; the length checks must use `length`.
336338isValidHex : Text -> Bool
337339isValidHex = \s -> do
338− let cs = chars s
339− let n = count cs
340− n > 0 && n / 2 * 2 == n && all (\c -> contains c "0123456789abcdef") cs
340+ let n = length s
341+ n > 0 && n / 2 * 2 == n && all (\c -> contains c "0123456789abcdef") (chars s)
341342
342343-- Recursively strip every trailing "." from `s` (DNS root form).
343344stripTrailingDot : Text -> Text
344345stripTrailingDot = \s -> if hasSuffix "." s
345−then stripTrailingDot (take (count (chars s) - 1) s)
346+then stripTrailingDot (take (length s - 1) s)
346347else s
347348
348349-- True when `s` refers to this server.
@@ -351,11 +352,18 @@ isLocalServer = \s -> s == serverHost
351352
352353-- True when `s` starts with `pre`.
353354hasPrefix : Text -> Text -> Bool
354−hasPrefix = \pre s -> take (count (chars pre)) s == pre
355+hasPrefix = \pre s -> take (length pre) s == pre
355356
356357-- True when `s` ends with `suffix`.
357358hasSuffix : Text -> Text -> Bool
358−hasSuffix = \suffix s -> drop (max 0 (count (chars s) - count (chars suffix))) s == suffix
359+hasSuffix = \suffix s -> drop (max 0 (length s - length suffix)) s == suffix
360+
361+-- Occurrences of the single character `c` in `s`. (`chars` can't be used for
362+-- this — it returns the distinct character *set*.)
363+countChar : Text -> Text -> Int
364+countChar = \c s -> if s == ""
365+then 0
366+else (if take 1 s == c then 1 else 0) + countChar c (drop 1 s)
359367
360368-- Block private/reserved/internal hostnames (SSRF defense). Skips obscure encoding
361369-- tricks (octal, hex, percent-encoding) but covers the high-value cases:
@@ -368,7 +376,7 @@ hasSuffix = \suffix s -> drop (max 0 (count (chars s) - count (chars suffix))) s
368376-- A legitimate `host:port` server name has exactly one `:`, so the multi-colon
369377-- check leaves that case intact.
370378isBadServerName : Text -> Bool
371−isBadServerName = \sn -> sn == "" || sn == "localhost" || any (\c -> not contains c "0123456789abcdefghijklmnopqrstuvwxyz-.:") (chars sn) || count (filter (\c -> c == ":") (chars sn)) > 1 || any (\p -> hasPrefix p sn) [
379+isBadServerName = \sn -> sn == "" || sn == "localhost" || any (\c -> not contains c "0123456789abcdefghijklmnopqrstuvwxyz-.:") (chars sn) || countChar ":" sn > 1 || any (\p -> hasPrefix p sn) [
372380 ".",
373381 ":",
374382 "[",