diff --git a/contrib/epee/include/net/http_protocol_handler.h b/contrib/epee/include/net/http_protocol_handler.h index 8b73964dd..8f1ddd14c 100644 --- a/contrib/epee/include/net/http_protocol_handler.h +++ b/contrib/epee/include/net/http_protocol_handler.h @@ -36,6 +36,7 @@ #include "net_utils_base.h" #include "http_auth.h" #include "http_base.h" +#include "syncobj.h" #undef MONERO_DEFAULT_LOG_CATEGORY #define MONERO_DEFAULT_LOG_CATEGORY "net.http" @@ -65,6 +66,9 @@ namespace net_utils critical_section m_lock; }; + // RPC limits groupable IPv6 clients by /64 to avoid per-address limit bypasses. + std::string get_rpc_connection_limit_key(const net_utils::network_address& address); + /************************************************************************/ /* */ /************************************************************************/ diff --git a/contrib/epee/include/net/http_protocol_handler.inl b/contrib/epee/include/net/http_protocol_handler.inl index 47a3c1d1a..a908c46a2 100644 --- a/contrib/epee/include/net/http_protocol_handler.inl +++ b/contrib/epee/include/net/http_protocol_handler.inl @@ -225,7 +225,7 @@ namespace net_utils CRITICAL_REGION_LOCAL(m_config.m_lock); if (m_config.m_connection_count) --m_config.m_connection_count; - auto elem = m_config.m_connections.find(m_conn_context.m_remote_address.host_str()); + auto elem = m_config.m_connections.find(get_rpc_connection_limit_key(m_conn_context.m_remote_address)); if (elem != m_config.m_connections.end()) { if (elem->second == 1 || elem->second == 0) @@ -243,7 +243,7 @@ namespace net_utils bool simple_http_connection_handler::after_init_connection() { CRITICAL_REGION_LOCAL(m_config.m_lock); - ++m_config.m_connections[m_conn_context.m_remote_address.host_str()]; + ++m_config.m_connections[get_rpc_connection_limit_key(m_conn_context.m_remote_address)]; ++m_config.m_connection_count; m_initialized = true; return true; diff --git a/contrib/epee/include/net/http_server_impl_base.h b/contrib/epee/include/net/http_server_impl_base.h index a3173e4e8..f5ee5c941 100644 --- a/contrib/epee/include/net/http_server_impl_base.h +++ b/contrib/epee/include/net/http_server_impl_base.h @@ -155,7 +155,7 @@ namespace epee return true; const bool is_private = na.is_loopback() || na.is_local(); - const auto elem = config.m_connections.find(na.host_str()); + const auto elem = config.m_connections.find(net_utils::http::get_rpc_connection_limit_key(na)); if (elem != config.m_connections.end()) { if (is_private) diff --git a/contrib/epee/include/net/net_utils_base.h b/contrib/epee/include/net/net_utils_base.h index bed3b4227..8dfdc5e1a 100644 --- a/contrib/epee/include/net/net_utils_base.h +++ b/contrib/epee/include/net/net_utils_base.h @@ -33,6 +33,7 @@ #include #include #include +#include #include #include #include @@ -214,6 +215,9 @@ namespace net_utils END_KV_SERIALIZE_MAP() }; + bool should_group_ipv6_by_prefix(const boost::asio::ip::address_v6& ip); + boost::asio::ip::address_v6 get_ipv6_subnet_address(const boost::asio::ip::address_v6& ip, const std::size_t prefix_bits); + inline bool operator==(const ipv6_network_address& lhs, const ipv6_network_address& rhs) noexcept { return lhs.equal(rhs); } inline bool operator!=(const ipv6_network_address& lhs, const ipv6_network_address& rhs) noexcept diff --git a/contrib/epee/src/CMakeLists.txt b/contrib/epee/src/CMakeLists.txt index 1dc157379..eb841efbf 100644 --- a/contrib/epee/src/CMakeLists.txt +++ b/contrib/epee/src/CMakeLists.txt @@ -31,7 +31,7 @@ set(EPEE_INCLUDE_DIR_BASE "${CMAKE_CURRENT_SOURCE_DIR}/../include") # Add headers to the file list, to be able to search for them and autosave in IDEs. monero_find_all_headers(EPEE_HEADERS_PUBLIC "${EPEE_INCLUDE_DIR_BASE}") -monero_add_library(epee byte_slice.cpp byte_stream.cpp hex.cpp abstract_http_client.cpp http_auth.cpp mlog.cpp net_helper.cpp net_utils_base.cpp string_tools.cpp parserse_base_utils.cpp +monero_add_library(epee byte_slice.cpp byte_stream.cpp hex.cpp abstract_http_client.cpp http_auth.cpp http_protocol_handler.cpp mlog.cpp net_helper.cpp net_utils_base.cpp string_tools.cpp parserse_base_utils.cpp wipeable_string.cpp levin_base.cpp memwipe.c connection_basic.cpp network_throttle.cpp network_throttle-detail.cpp mlocker.cpp buffer.cpp net_ssl.cpp int-util.cpp portable_storage.cpp misc_language.cpp diff --git a/contrib/epee/src/http_protocol_handler.cpp b/contrib/epee/src/http_protocol_handler.cpp new file mode 100644 index 000000000..1fd3c3c56 --- /dev/null +++ b/contrib/epee/src/http_protocol_handler.cpp @@ -0,0 +1,44 @@ +// Copyright (c) 2026, The Monero Project +// +// All rights reserved. +// +// Redistribution and use in source and binary forms, with or without modification, are +// permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this list of +// conditions and the following disclaimer. +// +// 2. Redistributions in binary form must reproduce the above copyright notice, this list +// of conditions and the following disclaimer in the documentation and/or other +// materials provided with the distribution. +// +// 3. Neither the name of the copyright holder nor the names of its contributors may be +// used to endorse or promote products derived from this software without specific +// prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY +// EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +// MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL +// THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +// PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, +// STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF +// THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +#include "net/http_protocol_handler.h" + +namespace epee { namespace net_utils { namespace http +{ + std::string get_rpc_connection_limit_key(const net_utils::network_address& address) + { + if (address.get_type_id() == net_utils::ipv6_network_address::get_type_id()) + { + const boost::asio::ip::address_v6 ip = address.as().ip(); + if (net_utils::should_group_ipv6_by_prefix(ip)) + return net_utils::get_ipv6_subnet_address(ip, 64).to_string() + "/64"; + } + + return address.host_str(); + } +}}} diff --git a/contrib/epee/src/net_utils_base.cpp b/contrib/epee/src/net_utils_base.cpp index 36b55920d..3010227cf 100644 --- a/contrib/epee/src/net_utils_base.cpp +++ b/contrib/epee/src/net_utils_base.cpp @@ -1,6 +1,8 @@ #include "net/net_utils_base.h" +#include + #include #include @@ -53,6 +55,34 @@ namespace epee { namespace net_utils return ipv4_network_address{SWAP32BE(ipv4.to_uint()), address.port()}; } + static bool is_ipv6_unique_local(const boost::asio::ip::address_v6& ip) + { + const boost::asio::ip::address_v6::bytes_type bytes = ip.to_bytes(); + return (bytes[0] & 0xfe) == 0xfc; + } + + bool should_group_ipv6_by_prefix(const boost::asio::ip::address_v6& ip) + { + return !ip.is_unspecified() && !ip.is_loopback() && !ip.is_multicast() && + !ip.is_link_local() && !ip.is_site_local() && !is_ipv6_unique_local(ip) && + !ip.is_v4_mapped(); + } + + boost::asio::ip::address_v6 get_ipv6_subnet_address(const boost::asio::ip::address_v6& ip, const std::size_t prefix_bits) + { + boost::asio::ip::address_v6::bytes_type bytes = ip.to_bytes(); + const std::size_t bits = std::min(prefix_bits, std::size_t{128}); + if (bits < 128) + { + const std::size_t full_bytes = bits / 8; + const std::size_t remainder_bits = bits % 8; + if (remainder_bits != 0) + bytes[full_bytes] &= uint8_t(0xffu << (8 - remainder_bits)); + std::fill(bytes.begin() + full_bytes + (remainder_bits != 0), bytes.end(), 0); + } + return boost::asio::ip::address_v6(bytes); + } + bool ipv4_network_subnet::equal(const ipv4_network_subnet& other) const noexcept { return is_same_host(other) && m_mask == other.m_mask; } diff --git a/src/p2p/net_node.cpp b/src/p2p/net_node.cpp index c22eac574..339c029b5 100644 --- a/src/p2p/net_node.cpp +++ b/src/p2p/net_node.cpp @@ -176,7 +176,7 @@ namespace nodetool const command_line::arg_descriptor arg_pad_transactions = { "pad-transactions", "Pad relayed transactions to help defend against traffic volume analysis", false }; - const command_line::arg_descriptor arg_max_connections_per_ip = {"max-connections-per-ip", "Maximum number of p2p connections allowed from the same IP address", 1}; + const command_line::arg_descriptor arg_max_connections_per_ip = {"max-connections-per-ip", "Maximum number of inbound p2p connections allowed from the same IPv4 address or shared across a public IPv6 /64 subnet", 1}; boost::optional> get_proxies(boost::program_options::variables_map const& vm) { diff --git a/src/p2p/net_node.inl b/src/p2p/net_node.inl index b84bcebba..d6360dca1 100644 --- a/src/p2p/net_node.inl +++ b/src/p2p/net_node.inl @@ -156,6 +156,22 @@ namespace nodetool return is_forbidden_ipv4_mapped_ipv6_address(address); } + inline bool is_same_p2p_connection_limit_host(const epee::net_utils::network_address& left, const epee::net_utils::network_address& right) + { + if (left.get_type_id() == epee::net_utils::ipv6_network_address::get_type_id() && + right.get_type_id() == epee::net_utils::ipv6_network_address::get_type_id()) + { + const boost::asio::ip::address_v6 left_ip = left.as().ip(); + const boost::asio::ip::address_v6 right_ip = right.as().ip(); + if (epee::net_utils::should_group_ipv6_by_prefix(left_ip) && + epee::net_utils::should_group_ipv6_by_prefix(right_ip)) + return epee::net_utils::get_ipv6_subnet_address(left_ip, 64) == + epee::net_utils::get_ipv6_subnet_address(right_ip, 64); + } + + return left.is_same_host(right); + } + //----------------------------------------------------------------------------------- template node_server::~node_server() { @@ -3077,7 +3093,7 @@ namespace nodetool m_network_zones.at(epee::net_utils::zone::public_).m_net_server.get_config_object().foreach_connection([&](const p2p_connection_context& cntxt) { - if (cntxt.m_is_income && cntxt.m_remote_address.is_same_host(address)) { + if (cntxt.m_is_income && is_same_p2p_connection_limit_host(cntxt.m_remote_address, address)) { count++; // the only call location happens BEFORE foreach_connection list is updated diff --git a/src/rpc/core_rpc_server.cpp b/src/rpc/core_rpc_server.cpp index 142a7b58b..8fb2b92f7 100644 --- a/src/rpc/core_rpc_server.cpp +++ b/src/rpc/core_rpc_server.cpp @@ -3131,7 +3131,7 @@ namespace cryptonote const command_line::arg_descriptor core_rpc_server::arg_rpc_max_connections_per_public_ip = { "rpc-max-connections-per-public-ip" - , "Max RPC connections per public IP permitted" + , "Max RPC connections permitted per public IPv4 address or shared across a public IPv6 /64 subnet" , DEFAULT_RPC_MAX_CONNECTIONS_PER_PUBLIC_IP }; diff --git a/src/wallet/wallet_rpc_server.cpp b/src/wallet/wallet_rpc_server.cpp index 149d58e8f..e64df5866 100644 --- a/src/wallet/wallet_rpc_server.cpp +++ b/src/wallet/wallet_rpc_server.cpp @@ -143,7 +143,7 @@ namespace const command_line::arg_descriptor arg_wallet_dir = {"wallet-dir", "Directory for newly created wallets"}; const command_line::arg_descriptor arg_prompt_for_password = {"prompt-for-password", "Prompts for password when not provided", false}; const command_line::arg_descriptor arg_no_initial_sync = {"no-initial-sync", "Skips the initial sync before listening for connections", false}; - const command_line::arg_descriptor arg_rpc_max_connections_per_public_ip = {"rpc-max-connections-per-public-ip", "Max RPC connections per public IP permitted", DEFAULT_RPC_MAX_CONNECTIONS_PER_PUBLIC_IP}; + const command_line::arg_descriptor arg_rpc_max_connections_per_public_ip = {"rpc-max-connections-per-public-ip", "Max RPC connections permitted per public IPv4 address or shared across a public IPv6 /64 subnet", DEFAULT_RPC_MAX_CONNECTIONS_PER_PUBLIC_IP}; const command_line::arg_descriptor arg_rpc_max_connections_per_private_ip = {"rpc-max-connections-per-private-ip", "Max RPC connections per private and localhost IP permitted", DEFAULT_RPC_MAX_CONNECTIONS_PER_PRIVATE_IP}; const command_line::arg_descriptor arg_rpc_max_connections = {"rpc-max-connections", "Max RPC connections permitted", DEFAULT_RPC_MAX_CONNECTIONS}; const command_line::arg_descriptor arg_rpc_response_soft_limit = {"rpc-response-soft-limit", "Max response bytes that can be queued, enforced at next response attempt", DEFAULT_RPC_SOFT_LIMIT_SIZE}; diff --git a/tests/unit_tests/http.cpp b/tests/unit_tests/http.cpp index 5ecdd84d8..4341fbb6d 100644 --- a/tests/unit_tests/http.cpp +++ b/tests/unit_tests/http.cpp @@ -369,6 +369,83 @@ TEST(HTTP_Server_Auth, NotRequired) EXPECT_FALSE(auth.get_response(http::http_request_info{})); } +TEST(HTTP, RpcConnectionLimitKeyIPv6By64) +{ + const epee::net_utils::network_address ipv6_a{ + epee::net_utils::ipv6_network_address{ + boost::asio::ip::make_address_v6("2001:db8:abcd:1234:1111:2222:3333:4444"), 18081 + } + }; + const epee::net_utils::network_address ipv6_b{ + epee::net_utils::ipv6_network_address{ + boost::asio::ip::make_address_v6("2001:db8:abcd:1234:ffff:eeee:dddd:cccc"), 18081 + } + }; + const epee::net_utils::network_address ipv6_c{ + epee::net_utils::ipv6_network_address{ + boost::asio::ip::make_address_v6("2001:db8:abcd:1235::1"), 18081 + } + }; + + EXPECT_EQ("2001:db8:abcd:1234::/64", http::get_rpc_connection_limit_key(ipv6_a)); + EXPECT_EQ(http::get_rpc_connection_limit_key(ipv6_a), http::get_rpc_connection_limit_key(ipv6_b)); + EXPECT_NE(http::get_rpc_connection_limit_key(ipv6_a), http::get_rpc_connection_limit_key(ipv6_c)); + + const epee::net_utils::network_address loopback{ + epee::net_utils::ipv6_network_address{boost::asio::ip::address_v6::loopback(), 18081} + }; + EXPECT_EQ(loopback.host_str(), http::get_rpc_connection_limit_key(loopback)); + + const epee::net_utils::network_address link_local{ + epee::net_utils::ipv6_network_address{boost::asio::ip::make_address_v6("fe80::1"), 18081} + }; + EXPECT_EQ(link_local.host_str(), http::get_rpc_connection_limit_key(link_local)); + + const epee::net_utils::network_address unique_local_fc{ + epee::net_utils::ipv6_network_address{boost::asio::ip::make_address_v6("fc00::1"), 18081} + }; + EXPECT_EQ(unique_local_fc.host_str(), http::get_rpc_connection_limit_key(unique_local_fc)); + + const epee::net_utils::network_address unique_local_fd{ + epee::net_utils::ipv6_network_address{boost::asio::ip::make_address_v6("fd00::1"), 18081} + }; + EXPECT_EQ(unique_local_fd.host_str(), http::get_rpc_connection_limit_key(unique_local_fd)); + + const epee::net_utils::network_address unspecified{ + epee::net_utils::ipv6_network_address{boost::asio::ip::address_v6::any(), 18081} + }; + EXPECT_EQ(unspecified.host_str(), http::get_rpc_connection_limit_key(unspecified)); + + const epee::net_utils::network_address multicast{ + epee::net_utils::ipv6_network_address{boost::asio::ip::make_address_v6("ff00::1"), 18081} + }; + EXPECT_EQ(multicast.host_str(), http::get_rpc_connection_limit_key(multicast)); + + const epee::net_utils::network_address site_local{ + epee::net_utils::ipv6_network_address{boost::asio::ip::make_address_v6("fec0::1"), 18081} + }; + EXPECT_EQ(site_local.host_str(), http::get_rpc_connection_limit_key(site_local)); + + uint32_t ip = 0; + ASSERT_TRUE(epee::string_tools::get_ip_int32_from_string(ip, "203.0.113.1")); + const epee::net_utils::network_address ipv4{ + epee::net_utils::ipv4_network_address{ip, 18081} + }; + EXPECT_EQ(ipv4.host_str(), http::get_rpc_connection_limit_key(ipv4)); + + boost::asio::ip::address_v6::bytes_type bytes = {}; + bytes[10] = 0xff; + bytes[11] = 0xff; + bytes[12] = 203; + bytes[13] = 0; + bytes[14] = 113; + bytes[15] = 1; + const epee::net_utils::network_address mapped{ + epee::net_utils::ipv6_network_address{boost::asio::ip::address_v6{bytes}, 18081} + }; + EXPECT_EQ(mapped.host_str(), http::get_rpc_connection_limit_key(mapped)); +} + TEST(HTTP_Server_Auth, MissingAuth) { http::http_server_auth auth{{"foo", "bar"}, rng}; diff --git a/tests/unit_tests/node_server.cpp b/tests/unit_tests/node_server.cpp index b03826dac..6ef280d90 100644 --- a/tests/unit_tests/node_server.cpp +++ b/tests/unit_tests/node_server.cpp @@ -198,6 +198,98 @@ TEST(node_server, ipv4_mapped_ipv6_address) EXPECT_FALSE(nodetool::should_skip_connect_address(ipv6, true)); } +TEST(node_server, p2p_connection_limit_ipv6_by_64) +{ + const epee::net_utils::network_address ipv6_a{ + epee::net_utils::ipv6_network_address{ + boost::asio::ip::make_address_v6("2001:db8:abcd:1234:1111:2222:3333:4444"), 18080 + } + }; + const epee::net_utils::network_address ipv6_b{ + epee::net_utils::ipv6_network_address{ + boost::asio::ip::make_address_v6("2001:db8:abcd:1234:ffff:eeee:dddd:cccc"), 18080 + } + }; + const epee::net_utils::network_address ipv6_c{ + epee::net_utils::ipv6_network_address{ + boost::asio::ip::make_address_v6("2001:db8:abcd:1235::1"), 18080 + } + }; + + EXPECT_TRUE(nodetool::is_same_p2p_connection_limit_host(ipv6_a, ipv6_b)); + EXPECT_FALSE(nodetool::is_same_p2p_connection_limit_host(ipv6_a, ipv6_c)); + + const epee::net_utils::network_address loopback_a{ + epee::net_utils::ipv6_network_address{boost::asio::ip::address_v6::loopback(), 18080} + }; + const epee::net_utils::network_address loopback_b{ + epee::net_utils::ipv6_network_address{boost::asio::ip::make_address_v6("::2"), 18080} + }; + EXPECT_FALSE(nodetool::is_same_p2p_connection_limit_host(loopback_a, loopback_b)); + + const epee::net_utils::network_address link_local_a{ + epee::net_utils::ipv6_network_address{boost::asio::ip::make_address_v6("fe80::1"), 18080} + }; + const epee::net_utils::network_address link_local_b{ + epee::net_utils::ipv6_network_address{boost::asio::ip::make_address_v6("fe80::2"), 18080} + }; + EXPECT_FALSE(nodetool::is_same_p2p_connection_limit_host(link_local_a, link_local_b)); + + const epee::net_utils::network_address unique_local_a{ + epee::net_utils::ipv6_network_address{boost::asio::ip::make_address_v6("fc00::1"), 18080} + }; + const epee::net_utils::network_address unique_local_b{ + epee::net_utils::ipv6_network_address{boost::asio::ip::make_address_v6("fc00::2"), 18080} + }; + EXPECT_FALSE(nodetool::is_same_p2p_connection_limit_host(unique_local_a, unique_local_b)); + + const epee::net_utils::network_address unique_local_c{ + epee::net_utils::ipv6_network_address{boost::asio::ip::make_address_v6("fd00::1"), 18080} + }; + const epee::net_utils::network_address unique_local_d{ + epee::net_utils::ipv6_network_address{boost::asio::ip::make_address_v6("fd00::2"), 18080} + }; + EXPECT_FALSE(nodetool::is_same_p2p_connection_limit_host(unique_local_c, unique_local_d)); + + const epee::net_utils::network_address multicast_a{ + epee::net_utils::ipv6_network_address{boost::asio::ip::make_address_v6("ff00::1"), 18080} + }; + const epee::net_utils::network_address multicast_b{ + epee::net_utils::ipv6_network_address{boost::asio::ip::make_address_v6("ff00::2"), 18080} + }; + EXPECT_FALSE(nodetool::is_same_p2p_connection_limit_host(multicast_a, multicast_b)); + + const epee::net_utils::network_address site_local_a{ + epee::net_utils::ipv6_network_address{boost::asio::ip::make_address_v6("fec0::1"), 18080} + }; + const epee::net_utils::network_address site_local_b{ + epee::net_utils::ipv6_network_address{boost::asio::ip::make_address_v6("fec0::2"), 18080} + }; + EXPECT_FALSE(nodetool::is_same_p2p_connection_limit_host(site_local_a, site_local_b)); + + const epee::net_utils::network_address ipv4_a{MAKE_IPV4_ADDRESS_PORT(203, 0, 113, 1, 18080)}; + const epee::net_utils::network_address ipv4_b{MAKE_IPV4_ADDRESS_PORT(203, 0, 113, 2, 18080)}; + EXPECT_FALSE(nodetool::is_same_p2p_connection_limit_host(ipv4_a, ipv4_b)); + + boost::asio::ip::address_v6::bytes_type bytes_a = {}; + bytes_a[10] = 0xff; + bytes_a[11] = 0xff; + bytes_a[12] = 203; + bytes_a[13] = 0; + bytes_a[14] = 113; + bytes_a[15] = 1; + const epee::net_utils::network_address mapped_a{ + epee::net_utils::ipv6_network_address{boost::asio::ip::address_v6{bytes_a}, 18080} + }; + + boost::asio::ip::address_v6::bytes_type bytes_b = bytes_a; + bytes_b[15] = 2; + const epee::net_utils::network_address mapped_b{ + epee::net_utils::ipv6_network_address{boost::asio::ip::address_v6{bytes_b}, 18080} + }; + EXPECT_FALSE(nodetool::is_same_p2p_connection_limit_host(mapped_a, mapped_b)); +} + namespace { using path_t = boost::filesystem::path;