mirror of
https://github.com/simplex-chat/simplexmq.git
synced 2026-07-12 03:11:00 +00:00
9e4d8d95c0
The bare-name fallback and bareDomain parser would otherwise consume arbitrarily many non-space bytes via takeWhile1 before any validation or length check. A crafted multi-megabyte token would be decoded as UTF-8 and re-parsed in full before being rejected. Introduce `boundedNonSpace` (scan with 253-byte cap) at the two takeWhile1 sites. Inputs longer than 253 bytes leave residue that parseOnly's implicit endOfInput rejects, so the parser fails fast without ever allocating the full input. The bound is the DNS full-domain limit, chosen for being a familiar ceiling generous enough to cover any realistic SimpleX name (longest plausible @user.subdomain.simplex stays well under 100 bytes). No per-label cap — SimpleX names don't go through DNS label resolution and there's no semantic reason to constrain individual labels.
133 lines
5.4 KiB
Haskell
133 lines
5.4 KiB
Haskell
{-# LANGUAGE LambdaCase #-}
|
||
{-# LANGUAGE NamedFieldPuns #-}
|
||
{-# LANGUAGE OverloadedStrings #-}
|
||
{-# LANGUAGE StrictData #-}
|
||
{-# LANGUAGE TemplateHaskell #-}
|
||
|
||
-- | SimpleX name shape — parsed surface form for `@contact.simplex`,
|
||
-- `#group`, and similar. Shared between the agent (which receives names
|
||
-- from the user) and the server (which validates them on the RSLV path).
|
||
module Simplex.Messaging.SimplexName
|
||
( SimplexNameInfo (..),
|
||
SimplexNameDomain (..),
|
||
SimplexTLD (..),
|
||
SimplexNameType (..),
|
||
fullDomainName,
|
||
shortNameInfoStr,
|
||
)
|
||
where
|
||
|
||
import Control.Applicative (optional, (<|>))
|
||
import qualified Data.Aeson.TH as J
|
||
import qualified Data.Attoparsec.ByteString.Char8 as A
|
||
import qualified Data.Attoparsec.Text as AT
|
||
import Data.ByteString.Char8 (ByteString)
|
||
import qualified Data.ByteString.Char8 as B
|
||
import Data.Char (isDigit)
|
||
import Data.Functor (($>))
|
||
import Data.Text (Text)
|
||
import qualified Data.Text as T
|
||
import Data.Text.Encoding (encodeUtf8)
|
||
import Simplex.Messaging.Encoding.String
|
||
import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON)
|
||
import Simplex.Messaging.Util (safeDecodeUtf8, (<$?>))
|
||
|
||
data SimplexNameInfo = SimplexNameInfo
|
||
{ nameType :: SimplexNameType,
|
||
nameDomain :: SimplexNameDomain
|
||
}
|
||
deriving (Eq, Show)
|
||
|
||
data SimplexNameDomain = SimplexNameDomain
|
||
{ nameTLD :: SimplexTLD,
|
||
domain :: Text,
|
||
subDomain :: [Text] -- parent to child: ["b", "a"] for a.b.domain.simplex
|
||
}
|
||
deriving (Eq, Show)
|
||
|
||
data SimplexTLD = TLDSimplex | TLDTesting | TLDWeb
|
||
deriving (Eq, Show)
|
||
|
||
data SimplexNameType = NTPublicGroup | NTContact
|
||
deriving (Eq, Show)
|
||
|
||
instance StrEncoding SimplexNameType where
|
||
strEncode = \case
|
||
NTPublicGroup -> "#"
|
||
NTContact -> "@"
|
||
strP = A.char '#' $> NTPublicGroup <|> A.char '@' $> NTContact
|
||
|
||
nameLabelP :: AT.Parser Text
|
||
nameLabelP = T.intercalate "-" <$> AT.takeWhile1 (\c -> isNameLetter c || isDigit c) `AT.sepBy1` AT.char '-'
|
||
where
|
||
-- ASCII letters only. SNRC contracts hash byte sequences via keccak; ENS
|
||
-- uses UTS-46 + Punycode for IDN, which we do not implement. Admitting
|
||
-- Cyrillic / Greek / etc. via Data.Char.isAlpha would (a) make namehash
|
||
-- diverge from any IDN-aware registrar and (b) allow homograph spoofing
|
||
-- (Cyrillic а vs ASCII a hash to different on-chain records).
|
||
isNameLetter c = c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z'
|
||
|
||
-- | DoS defense for the bare-name / bare-domain entry points. The outer
|
||
-- parser would otherwise `takeWhile1 (not . isSpace)` unbounded, allowing
|
||
-- a crafted multi-megabyte token to be decoded and re-parsed before any
|
||
-- validation. Cap at 253 bytes (DNS full-domain limit) — generous against
|
||
-- any realistic SimpleX name and forces the surrounding `parseOnly`
|
||
-- (which requires consuming all input) to fail on oversized inputs.
|
||
boundedNonSpace :: A.Parser ByteString
|
||
boundedNonSpace = do
|
||
bs <- A.scan (0 :: Int) $ \i c ->
|
||
if i < 253 && not (A.isSpace c) then Just (i + 1) else Nothing
|
||
if B.null bs then fail "expected non-empty name token" else pure bs
|
||
|
||
instance StrEncoding SimplexNameInfo where
|
||
strEncode SimplexNameInfo {nameType, nameDomain} =
|
||
"simplex:/name" <> strEncode nameType <> strEncode nameDomain
|
||
strP = optional "simplex:/name" *> ((strP >>= infoP) <|> infoP NTPublicGroup)
|
||
where
|
||
infoP NTPublicGroup = SimplexNameInfo NTPublicGroup <$> (strP <|> bareName)
|
||
infoP NTContact = SimplexNameInfo NTContact <$> strP
|
||
bareName = parseBare . safeDecodeUtf8 <$?> boundedNonSpace
|
||
parseBare s = (\name -> SimplexNameDomain TLDSimplex name []) <$> AT.parseOnly (nameLabelP <* AT.endOfInput) s
|
||
|
||
instance StrEncoding SimplexNameDomain where
|
||
strEncode = encodeUtf8 . fullDomainName
|
||
strP = parseDomain . safeDecodeUtf8 <$?> boundedNonSpace
|
||
where
|
||
parseDomain s = AT.parseOnly (nameLabelP `AT.sepBy1` AT.char '.' <* AT.endOfInput) s >>= mkDomain
|
||
-- All labels lowercased: DNS labels are case-insensitive, and namehash is
|
||
-- byte-defined — preserving original case would make `Alice.simplex` and
|
||
-- `alice.simplex` resolve to different on-chain records. A mixed-case TLD
|
||
-- would also fall through to TLDWeb and route through the `tldAll`
|
||
-- catch-all entry instead of the TLDSimplex registry.
|
||
mkDomain labels = case reverse (map T.toLower labels) of
|
||
[] -> Left "empty name"
|
||
[_] -> Left "domain requires TLD"
|
||
"simplex" : name : sub -> Right (SimplexNameDomain TLDSimplex name sub)
|
||
"testing" : name : sub -> Right (SimplexNameDomain TLDTesting name sub)
|
||
_ -> Right (SimplexNameDomain TLDWeb (T.intercalate "." (map T.toLower labels)) [])
|
||
|
||
fullDomainName :: SimplexNameDomain -> Text
|
||
fullDomainName SimplexNameDomain {nameTLD, domain, subDomain} = T.intercalate "." (reverse subDomain ++ [domain] ++ tld')
|
||
where
|
||
tld' = case nameTLD of
|
||
TLDSimplex -> ["simplex"]
|
||
TLDTesting -> ["testing"]
|
||
TLDWeb -> []
|
||
|
||
shortNameInfoStr :: SimplexNameInfo -> Text
|
||
shortNameInfoStr = \case
|
||
SimplexNameInfo {nameType = NTPublicGroup, nameDomain = SimplexNameDomain {nameTLD = TLDSimplex, domain, subDomain = []}} -> "#" <> domain
|
||
info -> pfx <> fullDomainName (nameDomain info)
|
||
where
|
||
pfx = case nameType info of
|
||
NTPublicGroup -> "#"
|
||
NTContact -> "@"
|
||
|
||
$(J.deriveJSON (enumJSON $ dropPrefix "TLD") ''SimplexTLD)
|
||
|
||
$(J.deriveJSON (enumJSON $ dropPrefix "NT") ''SimplexNameType)
|
||
|
||
$(J.deriveJSON defaultJSON ''SimplexNameDomain)
|
||
|
||
$(J.deriveJSON defaultJSON ''SimplexNameInfo)
|