Skip to content

Commit 9cfdb55

Browse files
committed
smp-server: round 6 audit fixes (IPv6 SSRF, redirects, ASCII labels)
- Reject IPv6 aliases of 169.254.169.254 (IPv4-compatible / IPv4-mapped / 6to4 / NAT64) via numeric range check on parsed IPv6. - Disable HTTP redirects on the Eth RPC request. - Restrict SimplexName labels to ASCII (Cyrillic/Greek/full-width otherwise hash to different on-chain records and diverge from UTS-46 registrars). - pingEndpoint: only JsonRpcErr means "reachable"; transport/decode failures fail startup. boundedIniInt: readMaybe over partial read. - Add 127.0.0.0/8 and 0.0.0.0 to isLoopback. - Replace hand-rolled hex helpers with Data.ByteArray.Encoding; raise managerConnCount to match rpcMaxConcurrency; hex Show for NameOwner. - Fuse parallel http/https when into unless+case; drop reverse/re-reverse in mkDomain TLDWeb; first AbiInvariantViolated; Nothing <$ decodeAddress; forM_ (eitherToMaybe ...); >>= chain in NameOwner FromJSON. - Drop dead imports/exports/pragmas and two restating comments. - Tests: factor unsafeOwner/unsafeLink, addr1/2/3, testNamesConfig; add non-ASCII label rejection coverage.
1 parent b66d973 commit 9cfdb55

11 files changed

Lines changed: 221 additions & 167 deletions

File tree

src/Simplex/Messaging/Agent/Protocol.hs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -195,11 +195,10 @@ import qualified Data.Aeson.TH as J
195195
import qualified Data.Aeson.Types as JT
196196
import Data.Attoparsec.ByteString.Char8 (Parser)
197197
import qualified Data.Attoparsec.ByteString.Char8 as A
198-
import qualified Data.Attoparsec.Text as AT
199198
import qualified Data.ByteString.Base64.URL as B64
200199
import Data.ByteString.Char8 (ByteString)
201200
import qualified Data.ByteString.Char8 as B
202-
import Data.Char (isAlpha, isDigit, toLower, toUpper)
201+
import Data.Char (toLower, toUpper)
203202
import Data.Foldable (find)
204203
import Data.Functor (($>))
205204
import Data.Int (Int64)

src/Simplex/Messaging/Encoding.hs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ module Simplex.Messaging.Encoding
1515
smpEncodeList,
1616
smpListP,
1717
lenEncode,
18-
lenP,
1918
)
2019
where
2120

src/Simplex/Messaging/Protocol.hs

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -260,7 +260,7 @@ import Data.Maybe (fromMaybe, isJust, isNothing)
260260
import Data.String
261261
import Data.Text (Text)
262262
import qualified Data.Text as T
263-
import Data.Text.Encoding (decodeLatin1, decodeUtf8', encodeUtf8)
263+
import Data.Text.Encoding (decodeLatin1, encodeUtf8)
264264
import Data.Time.Clock.System (SystemTime (..), systemToUTCTime)
265265
import Data.Type.Equality
266266
import Data.Word (Word8, Word16)
@@ -566,12 +566,13 @@ type LinkId = QueueId
566566
-- | SMP queue ID on the server.
567567
type QueueId = EntityId
568568

569-
-- | Name resolution request. The client sends the canonical SimplexNameDomain
570-
-- (TLD always explicit) plus the SNRC contract address it expects the server
571-
-- to query. The server parses the domain (validating syntax) and checks the
572-
-- supplied contract against its INI whitelist before reading the chain — so a
573-
-- single names router can safely host multiple TLDs (each backed by its own
574-
-- SNRC contract) and reject clients that ask for the wrong one.
569+
-- | Name resolution request. The client sends the name in canonical
570+
-- SimplexNameDomain form (TLD always explicit) as a Text plus the SNRC
571+
-- contract address it expects the server to query. The server parses the
572+
-- name into SimplexNameDomain (validating syntax) and checks the supplied
573+
-- contract against its hardcoded TLD whitelist before reading the chain —
574+
-- so a single names router can safely host multiple TLDs (each backed by
575+
-- its own SNRC contract) and reject clients that ask for the wrong one.
575576
data RslvRequest = RslvRequest
576577
{ name :: Text,
577578
contract :: NameOwner
@@ -738,7 +739,12 @@ newtype EncFwdTransmission = EncFwdTransmission ByteString
738739
-- | 20-byte Ethereum address (NameRecord owner). Bare constructor not exported;
739740
-- use `mkNameOwner` to enforce the 20-byte invariant.
740741
newtype NameOwner = NameOwner ByteString
741-
deriving (Eq, Show)
742+
deriving (Eq)
743+
744+
-- Render the 20 raw bytes as "0x"-prefixed lowercase hex so log lines /
745+
-- traceShow output match the on-the-wire JSON form instead of Latin-1 garbage.
746+
instance Show NameOwner where
747+
show (NameOwner bs) = "NameOwner 0x" <> B.unpack (BAE.convertToBase BAE.Base16 bs)
742748

743749
mkNameOwner :: ByteString -> Either String NameOwner
744750
mkNameOwner bs
@@ -756,9 +762,7 @@ instance J.FromJSON NameOwner where
756762
parseJSON = J.withText "NameOwner" $ \t -> do
757763
-- Accept "0x" and "0X" prefixes (matches the Server-side hex decoder).
758764
let hex = fromMaybe t (T.stripPrefix "0x" t <|> T.stripPrefix "0X" t)
759-
case BAE.convertFromBase BAE.Base16 (encodeUtf8 hex) of
760-
Left e -> fail e
761-
Right bs -> either fail pure (mkNameOwner bs)
765+
either fail pure $ BAE.convertFromBase BAE.Base16 (encodeUtf8 hex) >>= mkNameOwner
762766

763767
instance J.ToJSON RslvRequest where
764768
toJSON RslvRequest {name, contract} = J.object ["name" J..= name, "contract" J..= contract]

src/Simplex/Messaging/Server.hs

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -249,8 +249,6 @@ smpServer started cfg@ServerConfig {transports, transportConfig = tCfg, startOpt
249249
closeServer = do
250250
pa <- asks (smpAgent . proxyAgent)
251251
ne <- asks namesEnv
252-
-- finally: if the proxy-agent close throws, we still release the resolver's
253-
-- HTTP connection manager.
254252
liftIO $ closeSMPClientAgent pa `E.finally` mapM_ closeNamesEnv ne
255253

256254
serverThread ::
@@ -664,8 +662,6 @@ smpServer started cfg@ServerConfig {transports, transportConfig = tCfg, startOpt
664662
map tshow [_pRequests, _pSuccesses, _pErrorsConnect, _pErrorsCompat, _pErrorsOther]
665663
showServiceStats ServiceStatsData {_srvAssocNew, _srvAssocDuplicate, _srvAssocUpdated, _srvAssocRemoved, _srvSubCount, _srvSubDuplicate, _srvSubQueues, _srvSubEnd} =
666664
map tshow [_srvAssocNew, _srvAssocDuplicate, _srvAssocUpdated, _srvAssocRemoved, _srvSubCount, _srvSubDuplicate, _srvSubQueues, _srvSubEnd]
667-
-- Column order matches `Stats.hs:strEncode NameResolverStatsData`:
668-
-- new counters appended at the end so existing CSV readers don't shift.
669665
showNameResolverStats NameResolverStatsData {_rslvReqs, _rslvSucc, _rslvNotFound, _rslvEthErrs, _rslvDisabled, _rslvBadName} =
670666
map tshow [_rslvReqs, _rslvSucc, _rslvNotFound, _rslvEthErrs, _rslvDisabled, _rslvBadName]
671667

src/Simplex/Messaging/Server/Env/STM.hs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,6 @@ import Simplex.Messaging.Server.MsgStore.STM
117117
import Simplex.Messaging.Server.MsgStore.Types
118118
import Simplex.Messaging.Server.Names (NamesConfig (..), NamesEnv, newNamesEnv, pingEndpoint)
119119
import Simplex.Messaging.Server.Names.Eth.RPC (scrubUrl)
120-
import Simplex.Messaging.Util (tshow)
121120
import Simplex.Messaging.Server.NtfStore
122121
import Simplex.Messaging.Server.QueueStore
123122
import Simplex.Messaging.Server.QueueStore.Postgres.Config
@@ -131,7 +130,7 @@ import Simplex.Messaging.TMap (TMap)
131130
import qualified Simplex.Messaging.TMap as TM
132131
import Simplex.Messaging.Transport (ASrvTransport, SMPVersion, THandleParams, TransportPeer (..), VersionRangeSMP)
133132
import Simplex.Messaging.Transport.Server
134-
import Simplex.Messaging.Util (ifM, whenM, ($>>=))
133+
import Simplex.Messaging.Util (ifM, tshow, whenM, ($>>=))
135134
import System.Directory (doesFileExist)
136135
import System.Exit (exitFailure)
137136
import System.IO (IOMode (..))

src/Simplex/Messaging/Server/Main.hs

Lines changed: 93 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,11 @@ import Simplex.Messaging.Client.Agent (SMPClientAgentConfig (..), defaultSMPClie
6666
import qualified Simplex.Messaging.Crypto as C
6767
import Simplex.Messaging.Encoding.String
6868
import Simplex.Messaging.Parsers (parseAll)
69-
import Simplex.Messaging.Protocol (BasicAuth (..), ProtoServerWithAuth (ProtoServerWithAuth), pattern SMPServer)
69+
import qualified Data.IP as IP
70+
import Data.Bits (shiftR, (.&.))
71+
import Data.Word (Word32)
72+
import Network.URI (URI (..), URIAuth (..), parseAbsoluteURI)
73+
import Simplex.Messaging.Protocol (BasicAuth (..), ProtoServerWithAuth (ProtoServerWithAuth), mkNameOwner, pattern SMPServer)
7074
import Simplex.Messaging.Server (AttachHTTP, exportMessages, importMessages, printMessageStats, runSMPServer)
7175
import Simplex.Messaging.Server.CLI
7276
import Simplex.Messaging.Server.Env.STM
@@ -76,8 +80,6 @@ import Simplex.Messaging.Server.Main.Init
7680
import Simplex.Messaging.Server.Web (EmbeddedWebParams (..), WebHttpsParams (..))
7781
import Simplex.Messaging.Server.MsgStore.Journal (JournalMsgStore (..), QStoreCfg (..), stmQueueStore)
7882
import Simplex.Messaging.Server.MsgStore.Types (MsgStoreClass (..), SQSType (..), SMSType (..), newMsgStore)
79-
import Network.URI (URI (..), URIAuth (..), parseAbsoluteURI)
80-
import Simplex.Messaging.Protocol (mkNameOwner)
8183
import Simplex.Messaging.Server.Names (NamesConfig (..), RpcAuth (..), TldRegistries (..))
8284
import Simplex.Messaging.Server.QueueStore.Postgres.Config
8385
import Simplex.Messaging.Server.StoreLog.ReadWrite (readQueueStore)
@@ -827,10 +829,15 @@ readNamesConfig ini
827829
-- against operator-misconfig footguns: 16 MiB response cap (worst-case
828830
-- per-call memory), 60 s timeout (no operator wants RSLV to hang longer),
829831
-- 1024 concurrent RPCs (any higher should run a separate names router).
830-
boundedIniInt def floor_ ceiling_ key = case readIniDefault def "NAMES" key ini of
831-
n | n >= floor_ && n <= ceiling_ -> n
832-
| otherwise ->
833-
error $ "[NAMES] " <> T.unpack key <> " must be in [" <> show floor_ <> ".." <> show ceiling_ <> "] (got " <> show n <> ")"
832+
boundedIniInt def floor_ ceiling_ key = case lookupValue "NAMES" key ini of
833+
Left _ -> def
834+
Right raw -> case readMaybe (T.unpack (T.strip raw)) of
835+
Nothing ->
836+
error $ "[NAMES] " <> T.unpack key <> ": not an integer (got " <> show raw <> ")"
837+
Just n
838+
| n >= floor_ && n <= ceiling_ -> n
839+
| otherwise ->
840+
error $ "[NAMES] " <> T.unpack key <> " must be in [" <> show floor_ <> ".." <> show ceiling_ <> "] (got " <> show n <> ")"
834841

835842
-- | Hardcoded SNRC contract whitelist. Placeholder addresses until the
836843
-- launch contracts are deployed; replaced in code rather than INI so
@@ -873,7 +880,10 @@ validateUrl url auth_ = do
873880
when (null host) $ Left "empty host"
874881
when (isBareIntegerHost host) $
875882
Left "bare-integer host not allowed (use a hostname or dotted-quad / bracketed IP); rejects 169.254.169.254 decimal/hex aliases"
876-
when (isLinkLocal host) $ Left "link-local host not allowed (rejects cloud metadata services)"
883+
when (isObfuscatedIpv4 host) $
884+
Left "non-canonical IPv4 form not allowed (use dotted-quad decimal 0-255 with no leading zeros); rejects inet_aton hex/octal/compact aliases of 169.254.169.254"
885+
when (isLinkLocal host || isForbiddenIpv6 host) $
886+
Left "link-local host not allowed (rejects cloud metadata services and IPv6 aliases of 169.254.0.0/16)"
877887
unless (null (uriUserInfo ua)) $ Left "userinfo (user:pass@) not allowed; use rpc_auth instead"
878888
case uriPort ua of
879889
"" -> Left "explicit port required (e.g. http://host:8545)"
@@ -886,26 +896,36 @@ validateUrl url auth_ = do
886896
let path = uriPath uri
887897
unless (path == "" || path == "/") $
888898
Left "URL path not allowed; API keys embedded in the path leak to logs — use rpc_auth instead"
889-
when (scheme == "http:" && not (isLoopback host)) $
890-
Left "http endpoint on a non-loopback host not allowed (plaintext leaks rpc_auth); use https"
891-
when (scheme == "https:" && not (isLoopback host) && isNothing auth_) $
892-
Left "https endpoint on a non-loopback host requires rpc_auth"
899+
unless (isLoopback host) $ case scheme of
900+
"http:" -> Left "http endpoint on a non-loopback host not allowed (plaintext leaks rpc_auth); use https"
901+
"https:" | isNothing auth_ -> Left "https endpoint on a non-loopback host requires rpc_auth"
902+
_ -> Right ()
893903
Right url
894904
where
895-
isLoopback h = h == "127.0.0.1" || h == "localhost" || h == "[::1]"
896-
-- IPv4 link-local 169.254.0.0/16, the IPv6 link-local prefix fe80::/10,
897-
-- and IPv4-mapped IPv6 forms of the cloud-metadata IP 169.254.169.254
898-
-- in every textual variant: dotted-quad, hex `a9fe:a9fe`, and the
899-
-- zero-run-expanded `0:0:0:0:0:ffff:…` / `0000:0000:…` forms.
900-
isLinkLocal h =
901-
"169.254." `isPrefixOf` h
902-
|| "[fe80:" `isPrefixOf` lh
903-
|| any (`isInfixOf` lh) v6MappedMetadata
905+
-- 127.0.0.0/8 and 0.0.0.0 both bind locally on Linux/BSD; treat them all
906+
-- as loopback for the http/auth gate so a misconfigured 0.0.0.0:8545 (or
907+
-- 127.0.0.5) doesn't get an Authorization header sent to a colocated
908+
-- service or silently dropped onto the wire.
909+
isLoopback = \case
910+
"localhost" -> True
911+
"[::1]" -> True
912+
"0.0.0.0" -> True
913+
h -> case parseDottedQuad h of
914+
Just (127, _, _, _) -> True
915+
_ -> False
916+
parseDottedQuad s = case splitOnDot s of
917+
[a, b, c, d] -> (,,,) <$> octet a <*> octet b <*> octet c <*> octet d
918+
_ -> Nothing
904919
where
905-
lh = map toLower h
906-
-- Substrings rather than prefixes so we catch every zero-run-expansion
907-
-- (`[::ffff:…`, `[0:0:0:0:0:ffff:…`, `[0000:0000:0000:0000:0000:ffff:…`).
908-
v6MappedMetadata = [":ffff:169.254.", ":ffff:a9fe:a9fe"] :: [String]
920+
octet o = case readMaybe o of
921+
Just n | (n :: Int) >= 0 && n <= 255 -> Just n
922+
_ -> Nothing
923+
splitOnDot s = case break (== '.') s of
924+
(chunk, []) -> [chunk]
925+
(chunk, _ : rest) -> chunk : splitOnDot rest
926+
-- IPv4 link-local 169.254.0.0/16 in dotted-quad form. IPv6 forms are
927+
-- delegated to isForbiddenIpv6 which parses the address numerically.
928+
isLinkLocal h = "169.254." `isPrefixOf` h
909929
-- Reject hostnames that look like decimal or `0x`/`0X`-hex integers —
910930
-- glibc's inet_aton accepts both as IPv4 aliases (`2852039166`,
911931
-- `0xa9fea9fe`, `0XA9FEA9FE` all resolve to 169.254.169.254). The literal
@@ -914,6 +934,54 @@ validateUrl url auth_ = do
914934
isBareIntegerHost h = case map toLower h of
915935
'0' : 'x' : rest -> all isHexDigit rest
916936
lh -> not (null lh) && all isDigit lh
937+
-- Reject dotted hosts whose every component is numeric (decimal or `0x`-hex)
938+
-- but which aren't strict canonical IPv4 (exactly 4 decimal octets 0..255 with
939+
-- no leading zeros). inet_aton accepts hex octets (`0xA9.0xFE.0xA9.0xFE`),
940+
-- octal octets (`0251.0376.0251.0376`, leading zero), mixed forms
941+
-- (`169.0376.169.254`), and compact 2/3-segment forms (`169.16689638`,
942+
-- `169.254.43518`) as aliases for 169.254.169.254. The literal-prefix check
943+
-- in isLinkLocal misses all of these; this predicate closes the gap.
944+
isObfuscatedIpv4 h
945+
| '.' `notElem` h = False
946+
| otherwise = allNumericParts && not strictCanonical
947+
where
948+
parts = splitOnDot h
949+
allNumericParts = not (null parts) && all isNumericPart parts
950+
isNumericPart p = case map toLower p of
951+
'0' : 'x' : rest@(_ : _) -> all isHexDigit rest
952+
lp@(_ : _) -> all isDigit lp
953+
_ -> False
954+
strictCanonical = length parts == 4 && all isStrictDecOctet parts
955+
isStrictDecOctet "0" = True
956+
isStrictDecOctet p@(c : _) =
957+
c /= '0' && all isDigit p && maybe False (\n -> (n :: Int) <= 255) (readMaybe p)
958+
isStrictDecOctet _ = False
959+
-- Strip the [...] brackets that parseAbsoluteURI keeps on IPv6 hosts, parse
960+
-- as numeric IPv6, and check 128-bit ranges:
961+
-- * fe80::/10 (link-local)
962+
-- * ::1 (loopback)
963+
-- * IPv4-compatible (::/96), IPv4-mapped (::ffff/96), 6to4 (2002::/16),
964+
-- NAT64 WKP (64:ff9b::/96) — when they alias an IPv4 in 169.254.0.0/16
965+
-- This covers every textual form of those addresses (compressed, uncompressed,
966+
-- mixed dotted-quad embed) because Data.IP normalises before we inspect bits.
967+
isForbiddenIpv6 h = maybe False (isForbiddenIpv6Word . IP.fromIPv6w) $
968+
stripBrackets h >>= readMaybe
969+
where
970+
stripBrackets ('[' : rest@(_ : _)) | last rest == ']' = Just (init rest)
971+
stripBrackets _ = Nothing
972+
-- Loopback (::1) is intentionally NOT in this list: loopback is gated
973+
-- separately by isLoopback for the http/auth decision.
974+
isForbiddenIpv6Word :: (Word32, Word32, Word32, Word32) -> Bool
975+
isForbiddenIpv6Word (w1, w2, w3, w4) =
976+
linkLocal || compatTo169 || mappedTo169 || sixToFour169 || nat64To169
977+
where
978+
linkLocal = (w1 `shiftR` 22) == 0x3fa -- fe80::/10
979+
is169254v4 = (w4 `shiftR` 16) == 0xa9fe
980+
high96Zero = w1 == 0 && w2 == 0
981+
compatTo169 = high96Zero && w3 == 0 && is169254v4
982+
mappedTo169 = high96Zero && w3 == 0xffff && is169254v4
983+
sixToFour169 = (w1 `shiftR` 16) == 0x2002 && (w1 .&. 0xffff) == 0xa9fe
984+
nat64To169 = w1 == 0x0064ff9b && w2 == 0 && w3 == 0 && is169254v4
917985

918986
-- | Parse an rpc_auth INI value. Scheme keyword is case-insensitive so
919987
-- "Bearer <token>" / "BEARER <token>" (Caddy / RFC 7235 convention) work

src/Simplex/Messaging/Server/Names.hs

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ module Simplex.Messaging.Server.Names
2929
where
3030

3131
import Control.Applicative ((<|>))
32-
import Control.Monad (guard, unless, when)
32+
import Control.Monad (forM_, guard, unless, when)
3333
import qualified Control.Exception as E
3434
import Control.Logger.Simple (logError)
3535
import Data.ByteString.Char8 (ByteString)
@@ -40,6 +40,7 @@ import qualified Data.Text as T
4040
import Data.Text.Encoding (encodeUtf8)
4141
import Data.Time.Clock.POSIX (getPOSIXTime)
4242
import Simplex.Messaging.Encoding.String (strDecode)
43+
import Simplex.Messaging.Util (eitherToMaybe)
4344
import Simplex.Messaging.Protocol (NameOwner, NameRecord (..), RslvRequest (..), unNameOwner)
4445
import Simplex.Messaging.Server.Names.Eth.RPC (EthRpcEnv, EthRpcError (..), RpcAuth (..), closeEthRpcEnv, ethCallReal, newEthRpcEnv)
4546
import Simplex.Messaging.Server.Names.Eth.SNRC (decodeAddress, decodeGetRecord, encodeGetRecord, isZeroOwner, namehash)
@@ -127,9 +128,11 @@ verifyRslv NamesEnv {config} RslvRequest {name, contract} = case strDecode (enco
127128

128129
-- | Reach the configured endpoint with a harmless probe call to confirm
129130
-- network reachability. Uses any configured contract address (the parser
130-
-- guarantees at least one is set). Returns Left only on transport-level
131-
-- failures; JSON-RPC errors (misconfigured address etc.) are treated as
132-
-- "endpoint reachable" — that distinction surfaces later via rslvEthErrs.
131+
-- guarantees at least one is set). A JSON-RPC error (e.g. unknown contract
132+
-- on a healthy node) is treated as "endpoint reachable". HTTP transport
133+
-- failures, oversized responses, and non-JSON bodies (operator pointing at
134+
-- the wrong service) all surface as Left so startup fails loudly rather
135+
-- than every RSLV silently incrementing rslvEthErrs.
133136
pingEndpoint :: NamesEnv -> IO (Either EthRpcError ())
134137
pingEndpoint NamesEnv {ethCall, config} = case anyAddress (tldRegistries config) of
135138
Nothing -> pure (Right ())
@@ -141,9 +144,9 @@ pingEndpoint NamesEnv {ethCall, config} = case anyAddress (tldRegistries config)
141144
ethCall (unNameOwner addr) (encodeGetRecord (namehash ""))
142145
pure $ case r of
143146
Nothing -> Left ProbeTimedOut
144-
Just (Left e@(HttpFailure _)) -> Left e
145-
Just (Left e@(HttpStatusErr _)) -> Left e
146-
Just _ -> Right ()
147+
Just (Left JsonRpcErr {}) -> Right () -- node answered, just doesn't know this contract
148+
Just (Left e) -> Left e
149+
Just (Right _) -> Right ()
147150
where
148151
anyAddress TldRegistries {tldSimplex, tldTesting, tldAll} =
149152
tldSimplex <|> tldTesting <|> tldAll
@@ -178,9 +181,8 @@ fetch env@NamesEnv {ethCall} contract d =
178181
-- an operator who enables [NAMES] against a working SNRC contract sees
179182
-- the resolver is functionally stubbed.
180183
notFoundWithPlaceholderWarn ret = do
181-
case decodeAddress 32 ret of
182-
Right owner -> unless (isZeroOwner owner) (warnPlaceholderOnce env)
183-
Left _ -> pure ()
184+
forM_ (eitherToMaybe (decodeAddress 32 ret)) $ \owner ->
185+
unless (isZeroOwner owner) (warnPlaceholderOnce env)
184186
pure (Left NotFound)
185187
-- Defense in depth: the SNRC contract should already return the
186188
-- zero-owner sentinel for expired records, but a buggy / pre-upgrade

0 commit comments

Comments
 (0)