Skip to content

Commit f6bc547

Browse files
committed
Add per connection/ip/account rate limiting with basic configuration
1 parent f7a4f5b commit f6bc547

11 files changed

Lines changed: 655 additions & 75 deletions

File tree

‎docs/rate_limiting.md‎

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
# monero-lws Rate Limiting
2+
3+
monero-lws has built-in rate limiting by connection, IP address, or account
4+
depending on the context. The server will return code `429` or `503` depending
5+
how a limit has been hit. The `429` error codes can be turned off by setting
6+
`--rate-limit 0` as a runtime option, OR by build time option by setting
7+
`config::rate::max_calls_per_second = 0` in `src/config.h`. Disabling by build
8+
time option has roughly a ~15% increase in performance, but this is difficult
9+
to measure accurately (it is likely less).
10+
11+
The default is `--rate-limit=50`, which is ~50 API calls per-second per
12+
account, or ~12 API calls per second by connection/IP. A weighting for
13+
`/get_random_outs` and `/login` when `"create_account": true` restrict those
14+
endpoints usage further.
15+
16+
Every `rate::config::max_calls_window` (default `5 seconds`) the
17+
calls-per-second calculation is re-targeted. This technically allows for
18+
`rate::config::max_calls_window * --rate-limit` bursts in the last second of
19+
processing, but this gets immediately re-adjusted at the next re-targeting. The
20+
current mechanism for "gaming" the system is to flood the system every
21+
`rate::config::max_calls_window` seconds, but this just averages to
22+
calls-per-second, so there is little to be gained other than co-ordinating a
23+
DDoS this way.
24+
25+
## Rate Limit Modes
26+
### By-Connection
27+
The rate limiting is done by connection when the remote IP is loopback or
28+
private IP UNTIL a viewkey for an enabled account is provided. This helps with
29+
Tor in particular - the hidden service can have PoW enabled to prevent DoS
30+
attacks, and each connection is throttled further by the rate limiting
31+
implementation. The limits by connection are `--rate-limit / 4`.
32+
33+
### By-IP
34+
The rate limiting is done by IP when the remote IP is public UNTIL a viewkey
35+
for an enabled account is provided. The limits by IP are
36+
`--rate-limit / 4`. The rate limits per IP are slowly aged out after all
37+
connections close, so an attacker cannot gain an advantage by slamming and
38+
closing connections.
39+
40+
### By-Account
41+
After a user provides a viewkey for an enabled account, the existing "call
42+
count" per connection/IP/account is transferred to the "new" account such that
43+
an attacker cannot gain an advantage by switching accounts. The limits per
44+
account are `--rate-limit`. The rate limits per account are slowly aged out
45+
after all connections close, so an attacker cannot gain an advantage by
46+
slamming and closing connections.
47+
48+
## Rate Limits (`429` HTTP error code)
49+
Each API call counts as `1` towards the per-second usage. `/get_random_outs`
50+
counts as `1 + config::rate::get_random_outs_weight` which defaults to `21`,
51+
and `/login` for a new account counts as
52+
`1 + config::rate::account_creation_weight` which defaults to `41`.
53+
54+
### `/get_random_outs`
55+
The default settings allow for ~1.6 `/get_random_outs` calls per-second unless
56+
tracking by-account is being done, and then ~2.3 calls per second afterward.
57+
Existing client implementations call `/get_unspent_outs` with a viewkey before
58+
calling `/get_random_outs`, so in practice all clients have enough limits for
59+
constructing a TX (unless fee estimation is particularly difficult).
60+
61+
### `/login` with account creation
62+
The default settings allow for ~0.31 account creations per second unless
63+
tracking by acccount where ~1.25 account creations per second are permitted.
64+
Creating an account after calling `/login` to an existing account is a
65+
strange edge case but permitted.
66+
67+
## Hard Limits (`503` HTTP error code)
68+
Several endpoints (`/daemon_status`, `/get_random_outs`, `/get_unspent_outs`,
69+
and `/submit_raw_tx`) are asynchronous due to calls into `monerod` to
70+
complete the response. All of these endpoints have build-time configurable
71+
limits (in `src/config.h`) for buffering while the `monerod` calls complete. If
72+
the hard-limit is reached a `503` error is returned. The goal to is prevent the
73+
server from hitting a out-of-memory termination by the OS.
74+
75+
### `config::rate::daemon_wait_queue_max`
76+
This build-time option, defaulted to `25,000`, limits the number of queued
77+
requests to the `/daemon_status`, `/get_random_outs`, and `get_unspent_outs`
78+
endpoints. If the user has never provided a viewkey to an enabled account, the
79+
limit imposed is `config::rate::daemon_wait_queue_max / 4`. This allows for
80+
valid account holders to continue service while unregistered users get
81+
throttled.
82+
83+
### `config::rate::submit_tx_max`
84+
This build-time option, defaulted to `100 MiB`, limits the bytes buffered for
85+
transactions to be broadcast to the Monero network. If the user has never
86+
provided a viewkey to an enabled account, the limit imposed is
87+
`config::rate::submit_tx_max / 4`. This allows for valid account holders to
88+
continue service while unregistered users get throttled.
89+
90+
## Connection Limits
91+
There are no limits on the number of connections currently. If you need such
92+
limits, configuration in the upstream proxy (nginx, tord, etc) are recommended.
93+
A future release may have better built-in support; general DDoS protections
94+
are likely better suited to other projects.
95+

‎src/config.h‎

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
#pragma once
22

3+
#include <chrono>
34
#include <cstdint>
45
#include "cryptonote_config.h"
56

@@ -9,13 +10,30 @@ namespace config
910
{
1011
extern cryptonote::network_type network;
1112

12-
//! Max queued clients when waiting for cacheable daemon response
13-
constexpr const std::size_t daemon_wait_queue_max = 25000;
13+
// All rate-limiting config options. See `docs/rate_limiting.md`
14+
namespace rate
15+
{
16+
//! Max (weighted) API calls per second (default). Zero == disabled at build
17+
constexpr const unsigned max_calls_per_second = 50;
1418

15-
//! Max queued clients for `get_random_outs`
16-
constexpr const std::size_t get_random_outs_max = 10000;
1719

18-
//! Max memory allowed for `submit_raw_tx` buffering
19-
constexpr const std::size_t submit_tx_max = 100 * 1024 * 1024;
20+
//! Weight for account creation rate limiting
21+
constexpr const unsigned account_creation_weight = 40;
22+
23+
//! Max queued clients when waiting for cacheable daemon response
24+
constexpr const std::size_t daemon_wait_queue_max = 25000;
25+
26+
//! Max queued clients for `get_random_outs`
27+
constexpr const std::size_t get_random_outs_max = 10000;
28+
29+
//! Weight for ip/account rate limiting
30+
constexpr const unsigned get_random_outs_weight = 20;
31+
32+
//! Calls per second window
33+
constexpr const std::chrono::seconds max_calls_window{5};
34+
35+
//! Max memory allowed for `submit_raw_tx` buffering
36+
constexpr const std::size_t submit_tx_max = 100 * 1024 * 1024;
37+
}
2038
}
2139
}

‎src/error.cpp‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,8 @@ namespace lws
109109
return "An unknown in-process message was received";
110110
case error::system_clock_invalid_range:
111111
return "System clock is out of range for account storage format";
112+
case error::rate_limited:
113+
return "IP or account has been rate limited";
112114
case error::rmq_failure:
113115
return "Failure within the RMQ library";
114116
case error::tx_relay_failed:

‎src/error.h‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ namespace lws
6868
signal_abort_scan, //!< In process ZMQ PUB to abort the scan was received
6969
signal_unknown, //!< An unknown in process ZMQ PUB was received
7070
system_clock_invalid_range, //!< System clock is out of range for storage format
71+
rate_limited, //!< IP or account has been rate limited
7172
rmq_failure, //!< Error within RMQ library
7273
tx_relay_failed //!< Daemon failed to relayed tx from REST client
7374
};

‎src/net/CMakeLists.txt‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,8 @@
2828

2929
add_subdirectory(http)
3030

31-
set(monero-lws-net_sources zmq_async.cpp)
32-
set(monero-lws-net_headers zmq_async.h)
31+
set(monero-lws-net_sources rate_limiter.cpp zmq_async.cpp)
32+
set(monero-lws-net_headers rate_limiter.h zmq_async.h)
3333

3434
add_library(monero-lws-net ${monero-lws-net_sources} ${monero-lws-net_headers})
3535
target_link_libraries(monero-lws-net monero-lws-net-http monero::libraries)

‎src/net/rate_limiter.cpp‎

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
// Copyright (c) 2025, The Monero Project
2+
// All rights reserved.
3+
//
4+
// Redistribution and use in source and binary forms, with or without modification, are
5+
// permitted provided that the following conditions are met:
6+
//
7+
// 1. Redistributions of source code must retain the above copyright notice, this list of
8+
// conditions and the following disclaimer.
9+
//
10+
// 2. Redistributions in binary form must reproduce the above copyright notice, this list
11+
// of conditions and the following disclaimer in the documentation and/or other
12+
// materials provided with the distribution.
13+
//
14+
// 3. Neither the name of the copyright holder nor the names of its contributors may be
15+
// used to endorse or promote products derived from this software without specific
16+
// prior written permission.
17+
//
18+
// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY
19+
// EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
20+
// MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL
21+
// THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
22+
// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
23+
// PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
24+
// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
25+
// STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF
26+
// THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
27+
28+
#include "rate_limiter.h"
29+
30+
#include <algorithm>
31+
#include <boost/asio/bind_executor.hpp>
32+
#include <boost/asio/post.hpp>
33+
#include <boost/thread/lock_guard.hpp>
34+
#include <cassert>
35+
#include <limits>
36+
37+
#include "config.h"
38+
#include "misc_log_ex.h"
39+
namespace net
40+
{
41+
namespace
42+
{
43+
static unsigned calls_per_second_(const std::chrono::steady_clock::time_point now, const std::chrono::steady_clock::time_point start, const unsigned calls) noexcept
44+
{
45+
const auto divisor =
46+
std::chrono::duration_cast<std::chrono::seconds>(now - start).count();
47+
return calls / ((0 < divisor) ? divisor : 1);
48+
}
49+
}
50+
51+
unsigned rate_limiter::calls_per_second(const std::chrono::steady_clock::time_point now) noexcept
52+
{
53+
return calls_per_second_(now, start_, calls_);
54+
}
55+
56+
unsigned rate_limiter::adjust_window(const std::chrono::steady_clock::time_point now) noexcept
57+
{
58+
/* make sure the # of calls decreases each adjustment period;
59+
duration_cast is iffy with asio timers. */
60+
calls_ = std::max(calls_per_second(now), unsigned(1)) - 1;
61+
start_ = now;
62+
return calls_;
63+
}
64+
65+
void rate_limiter::do_add_calls(std::shared_ptr<rate_limiter> more)
66+
{
67+
const boost::lock_guard<boost::mutex> lock{sync_};
68+
if (merged.insert(more).second)
69+
{
70+
const boost::lock_guard<boost::mutex> lock2{more->sync_};
71+
if (std::numeric_limits<unsigned>::max() - calls_ < more->calls_)
72+
calls_ = std::numeric_limits<unsigned>::max();
73+
else
74+
calls_ += more->calls_;
75+
}
76+
}
77+
78+
void rate_limiter::queue(std::shared_ptr<rate_limiter> self)
79+
{
80+
// duration_cast + division works better if its at least a 3 sec window
81+
static_assert(std::chrono::seconds{3} <= lws::config::rate::max_calls_window);
82+
assert(self_ != nullptr);
83+
self->timer_.expires_after(lws::config::rate::max_calls_window);
84+
self->timer_.async_wait(boost::asio::bind_executor(*self->strand_, window{self}));
85+
}
86+
87+
rate_limiter::rate_limiter(boost::asio::io_context& io)
88+
: strand_(std::make_shared<boost::asio::io_context::strand>(io)),
89+
timer_(io),
90+
start_(),
91+
merged(),
92+
calls_(0),
93+
sync_()
94+
{} // start_ is initialized on first call to `window::operator()`
95+
96+
void rate_limiter::window::operator()(const boost::system::error_code error)
97+
{
98+
if (self_ && error != boost::asio::error::operation_aborted)
99+
{
100+
bool check_close = false;
101+
const auto now = std::chrono::steady_clock::now();
102+
{
103+
const boost::lock_guard<boost::mutex> lock{self_->sync_};
104+
check_close = !self_->adjust_window(now);
105+
}
106+
107+
if (check_close) // api calls == 0
108+
{
109+
/* asio keeps a copy of the handler, so we have to check for
110+
`use_count() == 1` by `post`ing a routine that only has a `weak_ptr`
111+
Since both handlers run in the strand, the posted routine is queued,
112+
the handler copy is destroyed, and then this routine executes. The
113+
second shared_ptr for the strand is needed because we don't want to
114+
destroy the strand before the post executes. */
115+
std::shared_ptr<boost::asio::io_context::strand> strand = self_->strand_;
116+
std::weak_ptr<rate_limiter> check = self_;
117+
boost::asio::post(*self_->strand_, [strand = std::move(strand), check = std::move(check)] {
118+
auto self = check.lock();
119+
if (self)
120+
queue(std::move(self));
121+
else
122+
MERROR("DESTROYED");
123+
});
124+
}
125+
else
126+
queue(std::move(self_));
127+
}
128+
}
129+
130+
bool rate_limiter::rate_limited(const unsigned max_calls, const unsigned weight)
131+
{
132+
unsigned calls = 0;
133+
std::chrono::steady_clock::time_point start{};
134+
const auto now = std::chrono::steady_clock::now();
135+
136+
{
137+
const boost::lock_guard<boost::mutex> lock{sync_};
138+
calls = calls_;
139+
if (std::numeric_limits<unsigned>::max() - calls_ < weight)
140+
{
141+
calls_ = std::numeric_limits<unsigned>::max();
142+
calls = adjust_window(now);
143+
}
144+
else
145+
calls_ += weight;
146+
start = start_;
147+
}
148+
149+
return max_calls < calls_per_second_(now, start, calls);
150+
}
151+
152+
} // net

‎src/net/rate_limiter.h‎

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
// Copyright (c) 2025, The Monero Project
2+
// All rights reserved.
3+
//
4+
// Redistribution and use in source and binary forms, with or without modification, are
5+
// permitted provided that the following conditions are met:
6+
//
7+
// 1. Redistributions of source code must retain the above copyright notice, this list of
8+
// conditions and the following disclaimer.
9+
//
10+
// 2. Redistributions in binary form must reproduce the above copyright notice, this list
11+
// of conditions and the following disclaimer in the documentation and/or other
12+
// materials provided with the distribution.
13+
//
14+
// 3. Neither the name of the copyright holder nor the names of its contributors may be
15+
// used to endorse or promote products derived from this software without specific
16+
// prior written permission.
17+
//
18+
// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY
19+
// EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
20+
// MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL
21+
// THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
22+
// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
23+
// PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
24+
// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
25+
// STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF
26+
// THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
27+
28+
#pragma once
29+
30+
#include <boost/asio/io_context.hpp>
31+
#include <boost/asio/steady_timer.hpp>
32+
#include <boost/asio/strand.hpp>
33+
#include <boost/container/flat_set.hpp>
34+
#include <boost/system/error_code.hpp>
35+
#include <boost/thread/mutex.hpp>
36+
#include <chrono>
37+
#include <memory>
38+
#include <utility>
39+
40+
namespace net
41+
{
42+
//! Tracks weighted calls over a fixed time interval for rate limiting.
43+
class rate_limiter
44+
{
45+
std::shared_ptr<boost::asio::io_context::strand> strand_;
46+
boost::asio::steady_timer timer_;
47+
std::chrono::steady_clock::time_point start_;
48+
boost::container::flat_set<std::weak_ptr<const rate_limiter>, std::owner_less<std::weak_ptr<const rate_limiter>>> merged;
49+
unsigned calls_;
50+
boost::mutex sync_;
51+
52+
unsigned calls_per_second(const std::chrono::steady_clock::time_point now) noexcept;
53+
unsigned adjust_window(const std::chrono::steady_clock::time_point now) noexcept;
54+
void do_add_calls(std::shared_ptr<rate_limiter> more);
55+
56+
static void queue(std::shared_ptr<rate_limiter> self);
57+
58+
public:
59+
60+
//! Must call `window::operator()` before using this object
61+
explicit rate_limiter(boost::asio::io_context& io);
62+
63+
struct window
64+
{
65+
std::shared_ptr<rate_limiter> self_;
66+
void operator()(const boost::system::error_code error = {});
67+
};
68+
69+
void add_calls(std::shared_ptr<rate_limiter> more)
70+
{
71+
if (more)
72+
do_add_calls(std::move(more));
73+
}
74+
75+
bool rate_limited(unsigned max_calls, unsigned weight);
76+
};
77+
} // net

0 commit comments

Comments
 (0)