diff --git a/src/evo/deterministicmns.cpp b/src/evo/deterministicmns.cpp index ec00139807f6..1faff56d4e48 100644 --- a/src/evo/deterministicmns.cpp +++ b/src/evo/deterministicmns.cpp @@ -440,6 +440,12 @@ void CDeterministicMNList::AddMN(const CDeterministicMNCPtr& dmn, bool fBumpTota throw std::runtime_error(strprintf("%s: Can't add a masternode %s with a duplicate address=%s", __func__, dmn->proTxHash.ToString(), service_opt->ToStringAddrPort())); } + } else if (const auto domain_opt{entry.GetDomainPort()}) { + if (!AddUniqueProperty(*dmn, *domain_opt)) { + mnUniquePropertyMap = mnUniquePropertyMapSaved; + throw std::runtime_error(strprintf("%s: Can't add a masternode %s with a duplicate address=%s", + __func__, dmn->proTxHash.ToString(), domain_opt->ToStringAddrPort())); + } } else { mnUniquePropertyMap = mnUniquePropertyMapSaved; throw std::runtime_error( @@ -494,6 +500,10 @@ void CDeterministicMNList::UpdateMN(const CDeterministicMN& oldDmn, const std::s if (!DeleteUniqueProperty(dmn, *service_opt)) { return "internal error"; // This shouldn't be possible } + } else if (const auto domain_opt{old_entry.GetDomainPort()}) { + if (!DeleteUniqueProperty(dmn, *domain_opt)) { + return "internal error"; // This shouldn't be possible + } } else { return "invalid address"; } @@ -503,6 +513,10 @@ void CDeterministicMNList::UpdateMN(const CDeterministicMN& oldDmn, const std::s if (!AddUniqueProperty(dmn, *service_opt)) { return strprintf("duplicate (%s)", service_opt->ToStringAddrPort()); } + } else if (const auto domain_opt{new_entry.GetDomainPort()}) { + if (!AddUniqueProperty(dmn, *domain_opt)) { + return strprintf("duplicate (%s)", domain_opt->ToStringAddrPort()); + } } else { return "invalid address"; } @@ -583,6 +597,12 @@ void CDeterministicMNList::RemoveMN(const uint256& proTxHash) throw std::runtime_error(strprintf("%s: Can't delete a masternode %s with an address=%s", __func__, proTxHash.ToString(), service_opt->ToStringAddrPort())); } + } else if (const auto domain_opt{entry.GetDomainPort()}) { + if (!DeleteUniqueProperty(*dmn, *domain_opt)) { + mnUniquePropertyMap = mnUniquePropertyMapSaved; + throw std::runtime_error(strprintf("%s: Can't delete a masternode %s with an address=%s", __func__, + proTxHash.ToString(), domain_opt->ToStringAddrPort())); + } } else { mnUniquePropertyMap = mnUniquePropertyMapSaved; throw std::runtime_error(strprintf("%s: Can't delete a masternode %s with invalid address", __func__, @@ -1142,6 +1162,11 @@ bool CheckProRegTx(CDeterministicMNManager& dmnman, const CTransaction& tx, gsl: mnList.GetUniquePropertyMN(*service_opt)->collateralOutpoint != collateralOutpoint) { return state.Invalid(TxValidationResult::TX_BAD_SPECIAL, "bad-protx-dup-netinfo-entry"); } + } else if (const auto domain_opt{entry.GetDomainPort()}) { + if (mnList.HasUniqueProperty(*domain_opt) && + mnList.GetUniquePropertyMN(*domain_opt)->collateralOutpoint != collateralOutpoint) { + return state.Invalid(TxValidationResult::TX_BAD_SPECIAL, "bad-protx-dup-netinfo-entry"); + } } else { return state.Invalid(TxValidationResult::TX_BAD_SPECIAL, "bad-protx-netinfo-entry"); } @@ -1224,6 +1249,10 @@ bool CheckProUpServTx(CDeterministicMNManager& dmnman, const CTransaction& tx, g mnList.GetUniquePropertyMN(*service_opt)->proTxHash != opt_ptx->proTxHash) { return state.Invalid(TxValidationResult::TX_BAD_SPECIAL, "bad-protx-dup-netinfo-entry"); } + } else if (const auto domain_opt{entry.GetDomainPort()}) { + if (mnList.HasUniqueProperty(*domain_opt) && mnList.GetUniquePropertyMN(*domain_opt)->proTxHash != opt_ptx->proTxHash) { + return state.Invalid(TxValidationResult::TX_BAD_SPECIAL, "bad-protx-dup-netinfo-entry"); + } } else { return state.Invalid(TxValidationResult::TX_BAD_SPECIAL, "bad-protx-netinfo-entry"); } diff --git a/src/evo/deterministicmns.h b/src/evo/deterministicmns.h index 12643e5f1f1a..0353c46634fc 100644 --- a/src/evo/deterministicmns.h +++ b/src/evo/deterministicmns.h @@ -442,7 +442,17 @@ class CDeterministicMNList DMNL_NO_TEMPLATE(NetInfoInterface); DMNL_NO_TEMPLATE(std::shared_ptr); #undef DMNL_NO_TEMPLATE - return ::SerializeHash(v); + int ser_version{PROTOCOL_VERSION}; + if constexpr (std::is_same_v, CService>) { + // Special handling is required if we're using addresses that can only be (de)serialized using + // ADDRv2. Without this step, the address gets truncated, the hashmap gets contaminated with + // an invalid entry and subsequent attempts at registering ADDRv2 entries get blocked. We cannot + // apply this treatment ADDRv1 compatible addresses for backwards compatibility with the existing map. + if (!v.IsAddrV1Compatible()) { + ser_version |= ADDRV2_FORMAT; + } + } + return ::SerializeHash(v, /*nType=*/SER_GETHASH, /*nVersion=*/ser_version); } template [[nodiscard]] bool AddUniqueProperty(const CDeterministicMN& dmn, const T& v) diff --git a/src/evo/netinfo.cpp b/src/evo/netinfo.cpp index 2c4dc7ef712c..3a8a920d46eb 100644 --- a/src/evo/netinfo.cpp +++ b/src/evo/netinfo.cpp @@ -18,13 +18,54 @@ namespace { static std::unique_ptr g_main_params{nullptr}; static std::once_flag g_main_params_flag; +/** Maximum length of a label in a domain per RFC 1035 */ +static constexpr uint8_t DOMAIN_LABEL_MAX_LEN{63}; +/** Maximum possible length of a ASCII FQDN */ +static constexpr uint8_t DOMAIN_MAX_LEN{253}; +/** Minimum length of a FQDN */ +static constexpr uint8_t DOMAIN_MIN_LEN{3}; + +static constexpr std::string_view SAFE_CHARS_ALPHA{"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"}; static constexpr std::string_view SAFE_CHARS_IPV4{"1234567890."}; static constexpr std::string_view SAFE_CHARS_IPV4_6{"abcdefABCDEF1234567890.:[]"}; +static constexpr std::string_view SAFE_CHARS_RFC1035{"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.-"}; +static constexpr std::array TLDS_BAD{ + // ICANN resolution 2018.02.04.12 + ".mail", + // Infrastructure TLD + ".arpa", + // RFC 6761 + ".example", ".invalid", ".localhost", ".test", + // RFC 6762 + ".local", + // RFC 6762, Appendix G + ".corp", ".home", ".internal", ".intranet", ".lan", ".private", +}; +static constexpr std::array TLDS_PRIVACY{".i2p", ".onion"}; bool MatchCharsFilter(std::string_view input, std::string_view filter) { return std::all_of(input.begin(), input.end(), [&filter](char c) { return filter.find(c) != std::string_view::npos; }); } + +bool MatchSuffix(const std::string& str, Span list) +{ + if (str.empty()) return false; + for (const auto& suffix : list) { + if (suffix.size() > str.size()) continue; + if (std::string_view{str}.ends_with(suffix)) return true; + } + return false; +} + +bool IsAllowedPlatformHTTPPort(uint16_t port) +{ + switch (port) { + case 443: + return true; + } + return false; +} } // anonymous namespace bool IsNodeOnMainnet() { return Params().NetworkIDString() == CBaseChainParams::MAIN; } @@ -42,6 +83,57 @@ UniValue ArrFromService(const CService& addr) return obj; } +DomainPort::Status DomainPort::ValidateDomain(const std::string& addr) +{ + if (addr.length() > DOMAIN_MAX_LEN || addr.length() < DOMAIN_MIN_LEN) { + return DomainPort::Status::BadLen; + } + if (!MatchCharsFilter(addr, SAFE_CHARS_RFC1035)) { + return DomainPort::Status::BadChar; + } + if (addr.front() == '.' || addr.back() == '.') { + return DomainPort::Status::BadCharPos; + } + std::vector labels{SplitString(addr, '.')}; + if (labels.size() < 2) { + return DomainPort::Status::BadDotless; + } + for (const auto& label : labels) { + if (label.empty() || label.length() > DOMAIN_LABEL_MAX_LEN) { + return DomainPort::Status::BadLabelLen; + } + if (label.front() == '-' || label.back() == '-') { + return DomainPort::Status::BadLabelCharPos; + } + } + return DomainPort::Status::Success; +} + +DomainPort::Status DomainPort::Set(const std::string& addr, const uint16_t port) +{ + if (port == 0) { + return DomainPort::Status::BadPort; + } + const auto ret{ValidateDomain(addr)}; + if (ret == DomainPort::Status::Success) { + // Convert to lowercase to avoid duplication by changing case (domains are case-insensitive) + m_addr = ToLower(addr); + m_port = port; + } + return ret; +} + +DomainPort::Status DomainPort::Validate() const +{ + if (m_addr.empty() || m_addr != ToLower(m_addr)) { + return DomainPort::Status::Malformed; + } + if (m_port == 0) { + return DomainPort::Status::BadPort; + } + return ValidateDomain(m_addr); +} + bool NetInfoEntry::operator==(const NetInfoEntry& rhs) const { if (m_type != rhs.m_type) return false; @@ -65,6 +157,10 @@ bool NetInfoEntry::operator<(const NetInfoEntry& rhs) const if constexpr (std::is_same_v) { // Both the same type, compare as usual return lhs < rhs; + } else if constexpr ((std::is_same_v || std::is_same_v) && + (std::is_same_v || std::is_same_v)) { + // Differing types but both implement ToStringAddrPort(), lexicographical compare strings + return lhs.ToStringAddrPort() < rhs.ToStringAddrPort(); } // If lhs is monostate, it less than rhs; otherwise rhs is greater return std::is_same_v; @@ -81,12 +177,21 @@ std::optional NetInfoEntry::GetAddrPort() const return std::nullopt; } +std::optional NetInfoEntry::GetDomainPort() const +{ + if (const auto* data_ptr{std::get_if(&m_data)}; m_type == NetInfoType::Domain && data_ptr) { + ASSERT_IF_DEBUG(data_ptr->IsValid()); + return *data_ptr; + } + return std::nullopt; +} + uint16_t NetInfoEntry::GetPort() const { return std::visit( [](auto&& input) -> uint16_t { using T1 = std::decay_t; - if constexpr (std::is_same_v) { + if constexpr (std::is_same_v || std::is_same_v) { return input.GetPort(); } return 0; @@ -103,7 +208,8 @@ bool NetInfoEntry::IsTriviallyValid() const return std::visit( [this](auto&& input) -> bool { using T1 = std::decay_t; - static_assert(std::is_same_v || std::is_same_v, "Unexpected type"); + static_assert(std::is_same_v || std::is_same_v || std::is_same_v, + "Unexpected type"); if constexpr (std::is_same_v) { // Empty underlying data isn't a valid entry return false; @@ -112,6 +218,11 @@ bool NetInfoEntry::IsTriviallyValid() const if (m_type != NetInfoType::Service) return false; // Underlying data must meet surface-level validity checks for its type if (!input.IsValid()) return false; + } else if constexpr (std::is_same_v) { + // Type code should be truthful as it decides what underlying type is used when (de)serializing + if (m_type != NetInfoType::Domain) return false; + // Underlying data should at least meet surface-level validity checks + if (!input.IsValid()) return false; } return true; }, @@ -125,6 +236,8 @@ std::string NetInfoEntry::ToString() const using T1 = std::decay_t; if constexpr (std::is_same_v) { return strprintf("CService(addr=%s, port=%u)", input.ToStringAddr(), input.GetPort()); + } else if constexpr (std::is_same_v) { + return strprintf("DomainPort(addr=%s, port=%u)", input.ToStringAddr(), input.GetPort()); } return "[invalid entry]"; }, @@ -136,7 +249,7 @@ std::string NetInfoEntry::ToStringAddr() const return std::visit( [](auto&& input) -> std::string { using T1 = std::decay_t; - if constexpr (std::is_same_v) { + if constexpr (std::is_same_v || std::is_same_v) { return input.ToStringAddr(); } return "[invalid entry]"; @@ -149,7 +262,7 @@ std::string NetInfoEntry::ToStringAddrPort() const return std::visit( [](auto&& input) -> std::string { using T1 = std::decay_t; - if constexpr (std::is_same_v) { + if constexpr (std::is_same_v || std::is_same_v) { return input.ToStringAddrPort(); } return "[invalid entry]"; @@ -307,6 +420,10 @@ NetInfoStatus ExtNetInfo::ProcessCandidate(const NetInfoPurpose purpose, const N if (IsAddrPortDuplicate(candidate)) { return NetInfoStatus::Duplicate; } + if (candidate.GetDomainPort().has_value() && purpose != NetInfoPurpose::PLATFORM_HTTPS) { + // Domains only allowed for Platform HTTPS API + return NetInfoStatus::BadInput; + } if (auto it{m_data.find(purpose)}; it != m_data.end()) { // Existing entries list found, check limit auto& [_, entries] = *it; @@ -333,15 +450,43 @@ NetInfoStatus ExtNetInfo::ValidateService(const CService& service) if (!service.IsValid()) { return NetInfoStatus::BadAddress; } - if (!service.IsIPv4() && !service.IsIPv6()) { + if (!service.IsCJDNS() && !service.IsI2P() && !service.IsIPv4() && !service.IsIPv6() && !service.IsTor()) { return NetInfoStatus::BadType; } if (Params().RequireRoutableExternalIP() && !service.IsRoutable()) { return NetInfoStatus::NotRoutable; } - if (IsBadPort(service.GetPort()) || service.GetPort() == 0) { + const uint16_t service_port{service.GetPort()}; + if (service.IsI2P()) { + if (service_port != I2P_SAM31_PORT) { + // I2P SAM 3.1 and earlier don't support arbitrary ports + return NetInfoStatus::BadPort; + } + } else { + if (service_port == 0 || IsBadPort(service_port)) { + return NetInfoStatus::BadPort; + } + } + + return NetInfoStatus::Success; +} + +NetInfoStatus ExtNetInfo::ValidateDomainPort(const DomainPort& domain) +{ + if (!domain.IsValid()) { + return NetInfoStatus::BadInput; + } + const uint16_t domain_port{domain.GetPort()}; + if (domain_port == 0 || (IsBadPort(domain_port) && !IsAllowedPlatformHTTPPort(domain_port))) { return NetInfoStatus::BadPort; } + const std::string& addr{domain.ToStringAddr()}; + if (MatchSuffix(addr, TLDS_BAD) || MatchSuffix(addr, TLDS_PRIVACY)) { + return NetInfoStatus::BadInput; + } + if (const auto labels{SplitString(addr, '.')}; !MatchCharsFilter(labels.at(labels.size() - 1), SAFE_CHARS_ALPHA)) { + return NetInfoStatus::BadInput; + } return NetInfoStatus::Success; } @@ -357,15 +502,42 @@ NetInfoStatus ExtNetInfo::AddEntry(const NetInfoPurpose purpose, const std::stri std::string addr; uint16_t port{0}; SplitHostPort(input, port, addr); - // Contains invalid characters, unlikely to pass Lookup(), fast-fail + if (!MatchCharsFilter(addr, SAFE_CHARS_IPV4_6)) { - return NetInfoStatus::BadInput; + if (!MatchCharsFilter(addr, SAFE_CHARS_RFC1035)) { + // Neither IP:port safe nor domain-safe, we can safely assume it's bad input + return NetInfoStatus::BadInput; + } + + // Not IP:port safe but domain safe + if (MatchSuffix(addr, TLDS_PRIVACY)) { + // Special domain, try storing it as CService + CNetAddr netaddr; + if (netaddr.SetSpecial(addr)) { + const CService service{netaddr, port}; + const auto ret{ValidateService(service)}; + if (ret == NetInfoStatus::Success) { + return ProcessCandidate(purpose, NetInfoEntry{service}); + } + return ret; /* ValidateService() failed */ + } + } else if (DomainPort domain; domain.Set(addr, port) == DomainPort::Status::Success) { + // Regular domain + const auto ret{ValidateDomainPort(domain)}; + if (ret == NetInfoStatus::Success) { + return ProcessCandidate(purpose, NetInfoEntry{domain}); + } + return ret; /* ValidateDomainPort() failed */ + } + return NetInfoStatus::BadInput; /* CNetAddr::SetSpecial() or DomainPort::Set() failed */ } + // IP:port safe, try to parse it as IP:port if (auto service_opt{Lookup(addr, /*portDefault=*/port, /*fAllowLookup=*/false)}) { - const auto ret{ValidateService(*service_opt)}; + const auto service{MaybeFlipIPv6toCJDNS(*service_opt)}; + const auto ret{ValidateService(service)}; if (ret == NetInfoStatus::Success) { - return ProcessCandidate(purpose, NetInfoEntry{*service_opt}); + return ProcessCandidate(purpose, NetInfoEntry{service}); } return ret; /* ValidateService() failed */ } @@ -436,6 +608,15 @@ NetInfoStatus ExtNetInfo::Validate() const // Stores CService underneath but doesn't pass validation rules return ret; } + } else if (const auto domain_opt{entry.GetDomainPort()}) { + if (purpose != NetInfoPurpose::PLATFORM_HTTPS) { + // Domains only allowed for Platform HTTPS API + return NetInfoStatus::BadInput; + } + if (auto ret{ValidateDomainPort(*domain_opt)}; ret != NetInfoStatus::Success) { + // Stores DomainPort underneath but doesn't pass validation rules + return ret; + } } else { // Doesn't store valid type underneath return NetInfoStatus::Malformed; diff --git a/src/evo/netinfo.h b/src/evo/netinfo.h index 4f1cdb9f1717..3ea51992b79e 100644 --- a/src/evo/netinfo.h +++ b/src/evo/netinfo.h @@ -106,20 +106,101 @@ UniValue ArrFromService(const CService& addr); /** Equivalent to Params() if node is running on mainnet */ const CChainParams& MainParams(); +class DomainPort +{ +public: + enum class Status : uint8_t { + BadChar, + BadCharPos, + BadDotless, + BadLabelCharPos, + BadLabelLen, + BadLen, + BadPort, + Malformed, + + Success + }; + + static constexpr std::string_view StatusToString(const DomainPort::Status code) + { + switch (code) { + case DomainPort::Status::BadChar: + return "invalid character"; + case DomainPort::Status::BadCharPos: + return "bad domain character position"; + case DomainPort::Status::BadDotless: + return "prohibited dotless"; + case DomainPort::Status::BadLabelCharPos: + return "bad label character position"; + case DomainPort::Status::BadLabelLen: + return "bad label length"; + case DomainPort::Status::BadLen: + return "bad domain length"; + case DomainPort::Status::BadPort: + return "bad port"; + case DomainPort::Status::Malformed: + return "malformed"; + case DomainPort::Status::Success: + return "success"; + } // no default case, so the compiler can warn about missing cases + assert(false); + } + +private: + std::string m_addr{}; + uint16_t m_port{0}; + +private: + static DomainPort::Status ValidateDomain(const std::string& input); + +public: + DomainPort() = default; + template + DomainPort(deserialize_type, Stream& s) { s >> *this; } + + ~DomainPort() = default; + + bool operator<(const DomainPort& rhs) const { return std::tie(m_addr, m_port) < std::tie(rhs.m_addr, rhs.m_port); } + bool operator==(const DomainPort& rhs) const { return std::tie(m_addr, m_port) == std::tie(rhs.m_addr, rhs.m_port); } + bool operator!=(const DomainPort& rhs) const { return !(*this == rhs); } + + SERIALIZE_METHODS(DomainPort, obj) + { + READWRITE(obj.m_addr); + READWRITE(Using>(obj.m_port)); + } + + bool IsEmpty() const { return m_addr.empty() && m_port == 0; } + bool IsValid() const { return Validate() == DomainPort::Status::Success; } + DomainPort::Status Set(const std::string& addr, const uint16_t port); + DomainPort::Status Validate() const; + uint16_t GetPort() const { return m_port; } + std::string ToStringAddr() const { return m_addr; } + std::string ToStringAddrPort() const { return strprintf("%s:%d", m_addr, m_port); } +}; + class NetInfoEntry { public: enum NetInfoType : uint8_t { Service = 0x01, + Domain = 0x02, Invalid = 0xff }; private: uint8_t m_type{NetInfoType::Invalid}; - std::variant m_data{std::monostate{}}; + std::variant m_data{std::monostate{}}; public: NetInfoEntry() = default; + NetInfoEntry(const DomainPort& domain) + { + if (!domain.IsValid()) return; + m_type = NetInfoType::Domain; + m_data = domain; + } NetInfoEntry(const CService& service) { if (!service.IsValid()) return; @@ -142,6 +223,9 @@ class NetInfoEntry if (const auto* data_ptr{std::get_if(&m_data)}; m_type == NetInfoType::Service && data_ptr && data_ptr->IsValid()) { s << m_type << *data_ptr; + } else if (const auto* data_ptr{std::get_if(&m_data)}; + m_type == NetInfoType::Domain && data_ptr && data_ptr->IsValid()) { + s << m_type << *data_ptr; } else { s << NetInfoType::Invalid; } @@ -153,12 +237,17 @@ class NetInfoEntry OverrideStream s(&s_, /*nType=*/0, s_.GetVersion() | ADDRV2_FORMAT); s >> m_type; if (m_type == NetInfoType::Service) { - m_data = CService{}; try { - CService& service{std::get(m_data)}; + auto& service{m_data.emplace()}; s >> service; if (!service.IsValid()) { Clear(); } // Invalid CService, mark as invalid } catch (const std::ios_base::failure&) { Clear(); } // Deser failed, mark as invalid + } else if (m_type == NetInfoType::Domain) { + try { + auto& domain{m_data.emplace()}; + s >> domain; + if (!domain.IsValid()) { Clear(); } // Invalid DomainPort, mark as invalid + } catch (const std::ios_base::failure&) { Clear(); } // Deser failed, mark as invalid } else { Clear(); } // Invalid type code, mark as invalid } @@ -169,6 +258,7 @@ class NetInfoEntry } std::optional GetAddrPort() const; + std::optional GetDomainPort() const; uint16_t GetPort() const; bool IsEmpty() const { return *this == NetInfoEntry{}; } bool IsTriviallyValid() const; @@ -294,6 +384,7 @@ class ExtNetInfo final : public NetInfoInterface /** Validate CService candidate address against ruleset */ static NetInfoStatus ValidateService(const CService& service); + static NetInfoStatus ValidateDomainPort(const DomainPort& domain); private: uint8_t m_version{CURRENT_VERSION}; diff --git a/src/evo/specialtxman.cpp b/src/evo/specialtxman.cpp index 9fcffe753299..e7fe59efff89 100644 --- a/src/evo/specialtxman.cpp +++ b/src/evo/specialtxman.cpp @@ -262,6 +262,10 @@ bool CSpecialTxProcessor::BuildNewListFromBlock(const CBlock& block, gsl::not_nu if (newList.HasUniqueProperty(*service_opt)) { return state.Invalid(BlockValidationResult::BLOCK_CONSENSUS, "bad-protx-dup-netinfo-entry"); } + } else if (const auto domain_opt{entry.GetDomainPort()}) { + if (newList.HasUniqueProperty(*domain_opt)) { + return state.Invalid(BlockValidationResult::BLOCK_CONSENSUS, "bad-protx-dup-netinfo-entry"); + } } else { return state.Invalid(BlockValidationResult::BLOCK_CONSENSUS, "bad-protx-netinfo-entry"); } @@ -298,6 +302,11 @@ bool CSpecialTxProcessor::BuildNewListFromBlock(const CBlock& block, gsl::not_nu newList.GetUniquePropertyMN(*service_opt)->proTxHash != opt_proTx->proTxHash) { return state.Invalid(BlockValidationResult::BLOCK_CONSENSUS, "bad-protx-dup-netinfo-entry"); } + } else if (const auto domain_opt{entry.GetDomainPort()}) { + if (newList.HasUniqueProperty(*domain_opt) && + newList.GetUniquePropertyMN(*domain_opt)->proTxHash != opt_proTx->proTxHash) { + return state.Invalid(BlockValidationResult::BLOCK_CONSENSUS, "bad-protx-dup-netinfo-entry"); + } } else { return state.Invalid(BlockValidationResult::BLOCK_CONSENSUS, "bad-protx-netinfo-entry"); } diff --git a/src/test/evo_netinfo_tests.cpp b/src/test/evo_netinfo_tests.cpp index 7c20deebf4e9..a1df594c820b 100644 --- a/src/test/evo_netinfo_tests.cpp +++ b/src/test/evo_netinfo_tests.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include @@ -21,7 +22,7 @@ struct TestEntry { NetInfoStatus expected_ret_ext; }; -static const std::vector vals_main{ +static const std::vector addr_vals_main{ // Address and port specified {{NetInfoPurpose::CORE_P2P, "1.1.1.1:9999"}, NetInfoStatus::Success, NetInfoStatus::Success}, // - Port should default to default P2P core with MnNetInfo @@ -39,8 +40,16 @@ static const std::vector vals_main{ // - Non-IPv4 addresses are prohibited in MnNetInfo // - Any valid BIP155 address is allowed in ExtNetInfo {{NetInfoPurpose::CORE_P2P, "[2606:4700:4700::1111]:9999"}, NetInfoStatus::BadInput, NetInfoStatus::Success}, - // Domains are not allowed + // - MnNetInfo doesn't allow storing anything except a Core P2P address + // - Privacy network domains are allowed in ExtNetInfo but internet domains are not {{NetInfoPurpose::CORE_P2P, "example.com:9999"}, NetInfoStatus::BadInput, NetInfoStatus::BadInput}, + {{NetInfoPurpose::CORE_P2P, "pg6mmjiyjmcrsslvykfwnntlaru7p5svn6y2ymmju6nubxndf4pscryd.onion:9999"}, NetInfoStatus::BadInput, NetInfoStatus::Success}, + {{NetInfoPurpose::PLATFORM_P2P, "example.com:9999"}, NetInfoStatus::MaxLimit, NetInfoStatus::BadInput}, + {{NetInfoPurpose::PLATFORM_P2P, "pg6mmjiyjmcrsslvykfwnntlaru7p5svn6y2ymmju6nubxndf4pscryd.onion:9999"}, NetInfoStatus::MaxLimit, NetInfoStatus::Success}, + // - MnNetInfo doesn't allow storing anything except a Core P2P address + // - ExtNetInfo can store Platform HTTPS addresses *as domains* alongside privacy network domains + {{NetInfoPurpose::PLATFORM_HTTPS, "example.com:9999"}, NetInfoStatus::MaxLimit, NetInfoStatus::Success}, + {{NetInfoPurpose::PLATFORM_HTTPS, "pg6mmjiyjmcrsslvykfwnntlaru7p5svn6y2ymmju6nubxndf4pscryd.onion:9999"}, NetInfoStatus::MaxLimit, NetInfoStatus::Success}, // Incorrect IPv4 address {{NetInfoPurpose::CORE_P2P, "1.1.1.256:9999"}, NetInfoStatus::BadInput, NetInfoStatus::BadInput}, // Missing address @@ -100,7 +109,7 @@ void TestExtNetInfo(const std::vector& vals) BOOST_AUTO_TEST_CASE(mnnetinfo_rules_main) { - TestMnNetInfo(vals_main); + TestMnNetInfo(addr_vals_main); { // MnNetInfo only stores one value, overwriting prohibited @@ -122,9 +131,9 @@ BOOST_AUTO_TEST_CASE(mnnetinfo_rules_main) } } -BOOST_AUTO_TEST_CASE(extnetinfo_rules_main) { TestExtNetInfo(vals_main); } +BOOST_AUTO_TEST_CASE(extnetinfo_rules_main) { TestExtNetInfo(addr_vals_main); } -static const std::vector vals_reg{ +static const std::vector addr_vals_reg{ // - MnNetInfo doesn't mind using port 0 // - ExtNetInfo requires non-zero ports {{NetInfoPurpose::CORE_P2P, "1.1.1.1:0"}, NetInfoStatus::Success, NetInfoStatus::BadPort}, @@ -136,11 +145,29 @@ static const std::vector vals_reg{ {{NetInfoPurpose::CORE_P2P, "1.1.1.1:22"}, NetInfoStatus::Success, NetInfoStatus::BadPort}, }; -BOOST_FIXTURE_TEST_CASE(mnnetinfo_rules_reg, RegTestingSetup) { TestMnNetInfo(vals_reg); } +enum class ExpectedType : uint8_t { + CJDNS, + I2P, + Tor, +}; + +static const std::vector> privacy_addr_vals{ + {ExpectedType::CJDNS, "[fc00:3344:5566:7788:9900:aabb:ccdd:eeff]:9998", NetInfoStatus::Success}, + // ExtNetInfo can store I2P addresses as long as it uses port 0 + {ExpectedType::I2P, "udhdrtrcetjm5sxzskjyr5ztpeszydbh4dpl3pl4utgqqw2v4jna.b32.i2p:0", NetInfoStatus::Success}, + // ExtNetInfo can store onion addresses + {ExpectedType::Tor, "pg6mmjiyjmcrsslvykfwnntlaru7p5svn6y2ymmju6nubxndf4pscryd.onion:9998", NetInfoStatus::Success}, + // ExtNetInfo can store I2P addresses but non-zero ports are not allowed + {ExpectedType::I2P, "udhdrtrcetjm5sxzskjyr5ztpeszydbh4dpl3pl4utgqqw2v4jna.b32.i2p:9998", NetInfoStatus::BadPort}, + // ExtNetInfo can store onion addresses but zero ports are not allowed + {ExpectedType::Tor, "pg6mmjiyjmcrsslvykfwnntlaru7p5svn6y2ymmju6nubxndf4pscryd.onion:0", NetInfoStatus::BadPort}, +}; + +BOOST_FIXTURE_TEST_CASE(mnnetinfo_rules_reg, RegTestingSetup) { TestMnNetInfo(addr_vals_reg); } BOOST_FIXTURE_TEST_CASE(extnetinfo_rules_reg, RegTestingSetup) { - TestExtNetInfo(vals_reg); + TestExtNetInfo(addr_vals_reg); { // ExtNetInfo can store up to 4 entries per purpose code, check limit enforcement @@ -181,6 +208,48 @@ BOOST_FIXTURE_TEST_CASE(extnetinfo_rules_reg, RegTestingSetup) BOOST_CHECK(!netInfo.HasEntries(NetInfoPurpose::PLATFORM_HTTPS)); ValidateGetEntries(netInfo.GetEntries(), /*expected_size=*/2); } + + { + // ExtNetInfo has additional rules for domains + const std::vector domain_vals{ + // Port 80 (HTTP) is below the privileged ports threshold (1023), not allowed + {{NetInfoPurpose::PLATFORM_HTTPS, "example.com:80"}, NetInfoStatus::MaxLimit, NetInfoStatus::BadPort}, + // Port 443 (HTTPS) is below the privileged ports threshold (1023) but still allowed + {{NetInfoPurpose::PLATFORM_HTTPS, "example.com:443"}, NetInfoStatus::MaxLimit, NetInfoStatus::Success}, + // TLDs must be alphabetic to avoid ambiguation with IP addresses (per ICANN guidelines) + {{NetInfoPurpose::PLATFORM_HTTPS, "example.123:443"}, NetInfoStatus::MaxLimit, NetInfoStatus::BadInput}, + // .local is a prohibited TLD + {{NetInfoPurpose::PLATFORM_HTTPS, "somebodys-macbook-pro.local:9998"}, NetInfoStatus::MaxLimit, NetInfoStatus::BadInput}, + // DomainPort isn't used for storing privacy network TLDs like .onion + {{NetInfoPurpose::PLATFORM_HTTPS, "pg6mmjiyjmcrsslvykfwnntlaru7p5svn6y2ymmju6nubxndf4pscryd:9998"}, NetInfoStatus::MaxLimit, NetInfoStatus::BadInput}, + }; + TestExtNetInfo(domain_vals); + } + + // Privacy network entry checks + for (const auto& [type, input, expected_ret] : privacy_addr_vals) { + const bool expected_success{expected_ret == NetInfoStatus::Success}; + + ExtNetInfo netInfo{}; + BOOST_CHECK_EQUAL(netInfo.AddEntry(NetInfoPurpose::CORE_P2P, input), expected_ret); + ValidateGetEntries(netInfo.GetEntries(), /*expected_size=*/expected_success ? 1 : 0); + if (!expected_success) continue; + + // Type registration check + const CService service{netInfo.GetEntries().at(0).GetAddrPort().value()}; + BOOST_CHECK(service.IsValid()); + switch (type) { + case ExpectedType::CJDNS: + BOOST_CHECK(service.IsCJDNS()); + break; + case ExpectedType::I2P: + BOOST_CHECK(service.IsI2P()); + break; + case ExpectedType::Tor: + BOOST_CHECK(service.IsTor()); + break; + } // no default case, so the compiler can warn about missing cases + } } BOOST_AUTO_TEST_CASE(netinfo_ser) @@ -364,4 +433,67 @@ BOOST_AUTO_TEST_CASE(interface_equality) BOOST_CHECK(!util::shared_ptr_equal(ptr_lhs, ptr_rhs) && util::shared_ptr_not_equal(ptr_lhs, ptr_rhs)); } +BOOST_AUTO_TEST_CASE(domainport_rules) +{ + static const std::vector> domain_vals{ + // Domain name labels can be as small as one character long and remain valid + {"r.server-1.ab.cd", DomainPort::Status::Success}, + // Domain names labels can trail with numbers or consist entirely of numbers due to RFC 1123 + {"9998.9example7.ab", DomainPort::Status::Success}, + // dotless domains prohibited + {"abcd", DomainPort::Status::BadDotless}, + // no empty label (trailing delimiter) + {"abc.", DomainPort::Status::BadCharPos}, + // no empty label (leading delimiter) + {".abc", DomainPort::Status::BadCharPos}, + // no empty label (extra delimiters) + {"a..dot..b", DomainPort::Status::BadLabelLen}, + // ' is not a valid character in domains + {"somebody's macbook pro.local", DomainPort::Status::BadChar}, + // spaces are not a valid character in domains + {"somebodys macbook pro.local", DomainPort::Status::BadChar}, + // trailing hyphens are not allowed + {"-a-.bc.de", DomainPort::Status::BadLabelCharPos}, + // 2 (characters in domain) < 3 (minimum length) + {"ac", DomainPort::Status::BadLen}, + // 278 (characters in domain) > 253 (maximum limit) + {"Loremipsumdolorsitametconsecteturadipiscingelitseddoeiusmodtempor" + "incididuntutlaboreetdoloremagnaaliquaUtenimadminimveniamquisnostrud" + "exercitationullamcolaborisnisiutaliquipexeacommodoconsequatDuisaute" + "iruredolorinreprehenderitinvoluptatevelitessecillumdoloreeufugiatnullapariat.ur", DomainPort::Status::BadLen}, + // 64 (characters in label) > 63 (maximum limit) + {"loremipsumdolorsitametconsecteturadipiscingelitseddoeiusmodtempo.ri.nc", DomainPort::Status::BadLabelLen}, + }; + + for (const auto& [addr, retval] : domain_vals) { + DomainPort domain; + ExtNetInfo netInfo; + BOOST_CHECK_EQUAL(domain.Set(addr, 443), retval); + if (retval != DomainPort::Status::Success) { + BOOST_CHECK_EQUAL(domain.Validate(), DomainPort::Status::Malformed); // Empty values report as Malformed + BOOST_CHECK_EQUAL(netInfo.AddEntry(NetInfoPurpose::PLATFORM_HTTPS, domain.ToStringAddrPort()), + NetInfoStatus::BadInput); + } else { + BOOST_CHECK_EQUAL(domain.Validate(), DomainPort::Status::Success); + BOOST_CHECK_EQUAL(netInfo.AddEntry(NetInfoPurpose::PLATFORM_HTTPS, domain.ToStringAddrPort()), NetInfoStatus::Success); + } + } + + { + // DomainPort requires non-zero ports + DomainPort domain; + BOOST_CHECK_EQUAL(domain.Set("example.com", 0), DomainPort::Status::BadPort); + BOOST_CHECK_EQUAL(domain.Validate(), DomainPort::Status::Malformed); + } + + { + // DomainPort stores the domain in lower-case + DomainPort lhs, rhs; + BOOST_CHECK_EQUAL(lhs.Set("example.com", 9999), DomainPort::Status::Success); + BOOST_CHECK_EQUAL(rhs.Set(ToUpper("example.com"), 9999), DomainPort::Status::Success); + BOOST_CHECK_EQUAL(lhs.ToStringAddr(), rhs.ToStringAddr()); + BOOST_CHECK(lhs == rhs); + } +} + BOOST_AUTO_TEST_SUITE_END() diff --git a/test/functional/rpc_netinfo.py b/test/functional/rpc_netinfo.py index 5852fa4e40cc..8fb9cb2e6448 100755 --- a/test/functional/rpc_netinfo.py +++ b/test/functional/rpc_netinfo.py @@ -35,6 +35,21 @@ PROTXVER_BASIC = 2 PROTXVER_EXTADDR = 3 +# Sample domains +DOMAINS_CLR = [ + "server-1.example.com", + "server-2.example.com", +] +DOMAINS_TOR = [ + "kpgvmscirrdqpekbqjsvw5teanhatztpp2gl6eee4zkowvwfxwenqaid.onion", + "pg6mmjiyjmcrsslvykfwnntlaru7p5svn6y2ymmju6nubxndf4pscryd.onion", +] +DOMAINS_I2P = [ + "c4gfnttsuwqomiygupdqqqyy5y5emnk5c73hrfvatri67prd7vyq.b32.i2p", + "udhdrtrcetjm5sxzskjyr5ztpeszydbh4dpl3pl4utgqqw2v4jna.b32.i2p", + "ukeu3k5oycgaauneqgtnvselmt4yemvoilkln7jpvamvfx7dnkdq.b32.i2p", +] + class EvoNode: mn: MasternodeInfo node: TestNode @@ -191,6 +206,12 @@ def run_test(self): self.test_empty_fields() self.log.info("Test output masternode address fields for consistency (post-fork)") self.test_shims() + # Need to destroy masternodes as the next test will be re-creating them + self.node_evo.destroy_mn(self) + self.node_two.destroy_mn(self) + self.reconnect_nodes() + self.log.info("Test unique properties map duplication checks") + self.test_uniqueness() def test_validation_common(self): # Arrays of addresses with invalid inputs get refused @@ -326,6 +347,15 @@ def test_validation_extended(self): self.node_evo.register_mn(self, False, f"127.0.0.1:{self.node_evo.mn.nodePort}", DEFAULT_PORT_PLATFORM_P2P, [f"127.0.0.1:{DEFAULT_PORT_PLATFORM_HTTP}"])])[0]['allowed'] + # coreP2PAddrs and platformP2PAddrs accept privacy network domains and platformHTTPSAddrs additionally supports internet domains + # Note: I2P entries cannot be differentiated by port, they must always use port 0 + assert self.node_evo.node.testmempoolaccept([ + self.node_evo.register_mn(self, False, + [f"127.0.0.1:{self.node_evo.mn.nodePort}", f"{DOMAINS_TOR[0]}:{self.node_evo.mn.nodePort}", f"{DOMAINS_I2P[0]}:0"], + [f"127.0.0.1:{DEFAULT_PORT_PLATFORM_P2P}", f"{DOMAINS_TOR[0]}:{DEFAULT_PORT_PLATFORM_P2P}", f"{DOMAINS_I2P[1]}:0"], + [f"127.0.0.1:{DEFAULT_PORT_PLATFORM_HTTP}", f"{DOMAINS_TOR[0]}:{DEFAULT_PORT_PLATFORM_HTTP}", f"{DOMAINS_I2P[2]}:0", + f"{DOMAINS_CLR[0]}:{DEFAULT_PORT_PLATFORM_HTTP}"] )])[0]['allowed'] + # Port numbers may not be wrapped in arrays, either as integers or strings self.node_evo.register_mn(self, False, f"127.0.0.1:{self.node_evo.mn.nodePort}", [DEFAULT_PORT_PLATFORM_P2P], DEFAULT_PORT_PLATFORM_HTTP, -8, "Invalid param for platformP2PAddrs[0], must be string") @@ -511,5 +541,40 @@ def test_shims(self): self.node_evo.set_active_state(self, False) self.reconnect_nodes() + def test_uniqueness(self): + # Empty registrations are not registered as conflicts + self.node_evo.register_mn(self, True, "", "", "") + self.node_two.register_mn(self, True, "", "", "") + + # Validate that the unique properties map correctly recognizes entries as duplicates + self.node_evo.update_mn(self, True, + [f"127.0.0.1:{self.node_evo.mn.nodePort}", f"{DOMAINS_TOR[0]}:{self.node_evo.mn.nodePort}"], + [f"127.0.0.1:{DEFAULT_PORT_PLATFORM_P2P}", f"{DOMAINS_I2P[0]}:0"], + [f"127.0.0.1:{DEFAULT_PORT_PLATFORM_HTTP}", f"{DOMAINS_CLR[0]}:{DEFAULT_PORT_PLATFORM_HTTP}"]) + + def update_node_two(self, duplicate_addr = None, duplicate_tor = None, duplicate_i2p = None, duplicate_domain = None): + args = [ + self, True, + [duplicate_addr or f"127.0.0.2:{self.node_two.mn.nodePort}", duplicate_tor or f"{DOMAINS_TOR[1]}:{self.node_two.mn.nodePort}"], + [f"127.0.0.2:{DEFAULT_PORT_PLATFORM_P2P}", duplicate_i2p or f"{DOMAINS_I2P[1]}:0"], + [f"127.0.0.2:{DEFAULT_PORT_PLATFORM_HTTP}", duplicate_domain or f"{DOMAINS_CLR[1]}:{DEFAULT_PORT_PLATFORM_HTTP}"] + ] + if duplicate_addr or duplicate_tor or duplicate_i2p or duplicate_domain: + args += [-1, "bad-protx-dup-netinfo-entry"] + self.node_two.update_mn(*args) + + # Check for detection of duplicate IP:addr (CService) + update_node_two(self, duplicate_addr=f"127.0.0.1:{self.node_evo.mn.nodePort}") + + # Check for detection of duplicate privacy addr (CService) + update_node_two(self, duplicate_tor=f"{DOMAINS_TOR[0]}:{self.node_evo.mn.nodePort}") + update_node_two(self, duplicate_i2p=f"{DOMAINS_I2P[0]}:0") + + # Check for detection of duplicate internet addr (DomainPort) + update_node_two(self, duplicate_domain=f"{DOMAINS_CLR[0]}:{DEFAULT_PORT_PLATFORM_HTTP}") + + # All non-duplicate entries should still succeed + update_node_two(self) + if __name__ == "__main__": NetInfoTest().main()