[Security] Add per-address rate limiting for auth endpoints

Introduce a thread-safe RateLimiter that tracks attempts per key
(IP address) within a sliding time window, and wire it into the
authentication endpoints:

- Login: failed login attempts from an address are counted; once the
  configured maximum is exceeded within the window, further logins
  from that address are rejected with RespTooManyRequests. A
  successful login clears the failed attempts for that address.
- Registration: implement the previously stubbed
  tooManyRegistrationAttempts, limiting how many accounts can be
  created per address per window.
- Forgot-password: throttle both the email-request and the
  email-challenge paths per address.

New [security] settings with defaults:
  max_login_attempts_per_ip=5 / login_attempt_window_seconds=900
  max_registrations_per_ip=2 / registration_window_seconds=3600
  max_forgot_password_requests_per_ip=3 / forgot_password_window_seconds=3600

Adds unit tests for the RateLimiter (window limit, over-limit
blocking, clearing, per-key independence).

Took 3 minutes
This commit is contained in:
Lukas Brübach 2026-08-04 10:38:20 +02:00
parent 1ed9823b56
commit b2f63255f0
11 changed files with 238 additions and 3 deletions

View file

@ -7,6 +7,7 @@ add_test(NAME expression_test COMMAND expression_test)
add_test(NAME clamped_arithmetic_test COMMAND clamped_arithmetic_test)
add_test(NAME test_age_formatting COMMAND test_age_formatting)
add_test(NAME password_hash_test COMMAND password_hash_test)
add_test(NAME rate_limiter_test COMMAND rate_limiter_test)
add_test(NAME server_card_counter_test COMMAND server_card_counter_test)
add_test(NAME server_counter_test COMMAND server_counter_test)
@ -20,6 +21,7 @@ add_executable(expression_test expression_test.cpp)
add_executable(clamped_arithmetic_test clamped_arithmetic_test.cpp)
add_executable(test_age_formatting test_age_formatting.cpp)
add_executable(password_hash_test password_hash_test.cpp)
add_executable(rate_limiter_test ../servatrice/src/ratelimiter.cpp rate_limiter_test.cpp)
add_executable(deck_hash_performance_test deck_hash_performance_test.cpp)
add_executable(server_card_counter_test server_card_counter_test.cpp)
add_executable(server_counter_test server_counter_test.cpp)
@ -54,6 +56,7 @@ if(NOT GTEST_FOUND)
add_dependencies(clamped_arithmetic_test gtest)
add_dependencies(test_age_formatting gtest)
add_dependencies(password_hash_test gtest)
add_dependencies(rate_limiter_test gtest)
add_dependencies(deck_hash_performance_test gtest)
add_dependencies(server_card_counter_test gtest)
add_dependencies(server_counter_test gtest)
@ -71,6 +74,8 @@ target_link_libraries(
target_link_libraries(
password_hash_test libcockatrice_utility Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES}
)
target_include_directories(rate_limiter_test PRIVATE "${CMAKE_SOURCE_DIR}/servatrice/src")
target_link_libraries(rate_limiter_test Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES})
target_link_libraries(
deck_hash_performance_test libcockatrice_deck_list libcockatrice_utility Threads::Threads ${GTEST_BOTH_LIBRARIES}
${TEST_QT_MODULES}

View file

@ -0,0 +1,49 @@
#include "ratelimiter.h"
#include "gtest/gtest.h"
namespace
{
TEST(RateLimiterTest, AllowsAttemptsWithinLimit)
{
RateLimiter limiter;
ASSERT_FALSE(limiter.recordAttempt("ip", 3, 60));
ASSERT_FALSE(limiter.recordAttempt("ip", 3, 60));
ASSERT_FALSE(limiter.recordAttempt("ip", 3, 60));
ASSERT_FALSE(limiter.isBlocked("ip", 3, 60));
}
TEST(RateLimiterTest, BlocksAttemptsOverLimit)
{
RateLimiter limiter;
ASSERT_FALSE(limiter.recordAttempt("ip", 2, 60));
ASSERT_FALSE(limiter.recordAttempt("ip", 2, 60));
ASSERT_TRUE(limiter.recordAttempt("ip", 2, 60));
ASSERT_TRUE(limiter.isBlocked("ip", 2, 60));
}
TEST(RateLimiterTest, ClearAttempts)
{
RateLimiter limiter;
ASSERT_TRUE(limiter.recordAttempt("ip", 0, 60));
ASSERT_TRUE(limiter.isBlocked("ip", 0, 60));
limiter.clearAttempts("ip");
ASSERT_FALSE(limiter.isBlocked("ip", 0, 60));
}
TEST(RateLimiterTest, KeysAreIndependent)
{
RateLimiter limiter;
ASSERT_TRUE(limiter.recordAttempt("a", 0, 60));
ASSERT_FALSE(limiter.isBlocked("b", 0, 60));
ASSERT_TRUE(limiter.isBlocked("a", 0, 60));
}
} // namespace
int main(int argc, char **argv)
{
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}