on master help sign in
Fix 2 bugs: octal/leading-zero IPv4 SSRF, unvalidated gossip pubkey
commitdbba2b31206f015bc4b0ff86a5572d632726a7b3 authoriko <mail@iko.soy> dateFri 12 Jun 2026, 11:47 (4 months ago) parente1c294a
1 file changed+30−4
server.knot+30−4
server.knot+30−4
@@ -466,6 +466,23 @@ isReservedIPv4 = \host -> do
466466 let o2 = decimalValue (beforeDot (afterDot host))
467467 isDottedNumeric host && (o1 >= 224 || (o1 == 100 && o2 >= 64 && o2 <= 127) || (o1 == 198 && (o2 == 18 || o2 == 19)))
468468
469+-- True when a dotted-numeric host carries an octet with a redundant leading
470+-- zero (e.g. "0177", "0012"). Canonical IPv4 literals never do, but the system
471+-- resolver behind `fetch` (ureq -> getaddrinfo / inet_aton) reads a leading-zero
472+-- octet as OCTAL: "0012.0.0.1" dials 10.0.0.1 and "0177.0.0.1" dials 127.0.0.1,
473+-- while the decimal octet parse in `isReservedIPv4` (and the dotted-prefix list)
474+-- sees 12.0.0.1 / 177.0.0.1 and waves them through — an SSRF bypass of the same
475+-- class as the non-dotted "dword"/`0x` forms. Reject the ambiguous octal form
476+-- outright. Gated by `isDottedNumeric` so a digit-led hostname label like
477+-- "0foo.example.com" is never caught (it isn't all-digits-and-dots).
478+hasLeadingZeroOctet : Text -> Bool
479+hasLeadingZeroOctet = \host -> isDottedNumeric host && anyOctetLeadingZero host
480+anyOctetLeadingZero : Text -> Bool
481+anyOctetLeadingZero = \host -> do
482+ let octet = beforeDot host
483+ let rest = afterDot host
484+ (length octet > 1 && take 1 octet == "0") || (rest != "" && anyOctetLeadingZero rest)
485+
469486-- Block private/reserved/internal hostnames (SSRF defense). Skips percent-encoding
470487-- tricks but covers the high-value cases: `localhost`, RFC1918 ranges (10/8,
471488-- 172.16/12, 192.168/16) plus link-local
@@ -474,8 +491,10 @@ isReservedIPv4 = \host -> do
474491-- 224/4+240/4 (any first octet >= 224, via `isReservedIPv4`), bare IPv6
475492-- literals (any string with more than one `:` — covers loopback
476493-- `::`/`::1`, link-local `fe80::/10`, ULA `fc00::/7`, globals like `2001:db8::1`),
477−-- bracketed IPv6 literals, integer/octal "dword" IPv4 (`isNumericHost`) and the
478−-- `0x`-prefixed hex form, and the `.local` / `.internal` / `.arpa` / `.onion`
494+-- bracketed IPv6 literals, integer/octal "dword" IPv4 (`isNumericHost`), the
495+-- `0x`-prefixed hex form, octal dotted-octet IPv4 (any leading-zero octet, via
496+-- `hasLeadingZeroOctet` — the resolver reads "0177.0.0.1" as 127.0.0.1), and the
497+-- `.local` / `.internal` / `.arpa` / `.onion`
479498-- suffixes. Enforced on inbound requests (`handleRecvGossip`, so a peer can't
480499-- claim a bogus `fromServer`) and again at every federation egress site
481500-- (`sendBatchedGossip`, `retryPendingForwards`, `forkForwardsTo`) as
@@ -484,7 +503,7 @@ isReservedIPv4 = \host -> do
484503-- A legitimate `host:port` server name has exactly one `:`, so the multi-colon
485504-- check leaves that case intact.
486505isBadServerName : Text -> Bool
487−isBadServerName = \sn -> sn == "" || sn == "localhost" || hasPrefix "localhost:" sn || isNumericHost sn || isReservedIPv4 (beforeColon sn) || hasPrefix "0x" sn || any (\c -> not contains c "0123456789abcdefghijklmnopqrstuvwxyz-.:") (chars sn) || countChar ":" sn > 1 || any (\p -> hasPrefix p sn) [
506+isBadServerName = \sn -> sn == "" || sn == "localhost" || hasPrefix "localhost:" sn || isNumericHost sn || isReservedIPv4 (beforeColon sn) || hasLeadingZeroOctet (beforeColon sn) || hasPrefix "0x" sn || any (\c -> not contains c "0123456789abcdefghijklmnopqrstuvwxyz-.:") (chars sn) || countChar ":" sn > 1 || any (\p -> hasPrefix p sn) [
488507 ".",
489508 ":",
490509 "[",
@@ -1049,7 +1068,14 @@ handleRecvGossip = \events fromServer -> do
10491068 newlyOnline <- atomic (do
10501069 pres <- *presence
10511070 let collected = fold (\acc e -> case e.pubkey of
1052− Just {value: pubkey} -> if e.eventType == "online"
1071+ -- `pubkey` is nested inside the `events` list, so the route-boundary
1072+ -- refinement never validates it (same nested-list gap that `/messages`
1073+ -- works around with an explicit `isValidExactHex 64` check). Reject a
1074+ -- malformed key here so a peer can't poison `*presence` with non-hex
1075+ -- rows that then spawn bogus-`toKey` forward entries.
1076+ Just {value: pubkey} -> if not isValidExactHex 64 pubkey
1077+ then acc
1078+ else if e.eventType == "online"
10531079 then {
10541080 touched: union acc.touched [pubkey],
10551081 added: union acc.added [{pubkey, server: fromServer, expiresAt: t + onlineGossipTtl}]