Files
Cockatrice/tests/rate_limiter_test.cpp
BruebachL 456d9d9e70
Some checks failed
CodeQL / Analyze (cpp) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
Build Desktop / Configure (push) Has been cancelled
Build Desktop / Debian 13 (push) Has been cancelled
Build Desktop / Debian 12 (push) Has been cancelled
Build Desktop / Fedora 44 (push) Has been cancelled
Build Desktop / Fedora 43 (push) Has been cancelled
Build Desktop / Servatrice_Debian 12 (push) Has been cancelled
Build Desktop / Ubuntu 24.04 (push) Has been cancelled
Build Desktop / Ubuntu 26.04 (push) Has been cancelled
Build Desktop / Arch (push) Has been cancelled
Build Desktop / Ubuntu 26.04 cover (push) Has been cancelled
Build Desktop / macOS 13 Intel (push) Has been cancelled
Build Desktop / macOS 14 (push) Has been cancelled
Build Desktop / macOS 15 (push) Has been cancelled
Build Desktop / macOS 26 Debug (push) Has been cancelled
Build Desktop / Windows 10 (push) Has been cancelled
Build Desktop / Prune caches (push) Has been cancelled
Build Docker / Servatrice (arm) (push) Has been cancelled
Build Docker / Servatrice (x86) (push) Has been cancelled
Build Docker / Publish multi-platform Servatrice image (push) Has been cancelled
[Server] Inform client of login/registration rate limit timeout (#7468)
* [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

* [Server] Fix rate limiter: disable semantics, draining window, bounded keys, pre-work login gate

- recordAttempt()/isBlocked() return false when maxAttempts <= 0 or windowSeconds <= 0, so the documented 'set to 0 to disable' actually disables instead of blocking every address permanently
- Keys at their limit are rejected without recording, so repeated attempts at the attacker's pace cannot keep sliding the window and holding the lockout open; a block now clears once the recorded attempts age out of windowSeconds
- Attempts map is bounded: an opportunistic prune (every 60s) erases keys whose attempts have fully aged out
- Login is throttled before the work: isLoginRateLimited() (a new virtual on Server, backed by isBlocked()) gates loginUser(), so a locked-out address no longer burns a database round trip and password verification per attempt
- recordAttemptAt()/isBlockedAt() time seams make the sliding-window behaviour deterministically testable; tests rewritten for the real semantics (no reliance on the maxAttempts=0 bug, no sleeps)

* [Server] Inform client of login/registration rate limit timeout

- Add retryAfterSeconds/retryAfterSecondsAt to RateLimiter to compute
  seconds until oldest attempt exits the sliding window
- Expose loginRateLimitRemainingSeconds via Server/Servatrice
- Include denied_end_time (absolute epoch) in RespTooManyRequests for
  login pre-auth and when transitioning to lockout after failed attempt
- Include denied_end_time in registration rate limit response
- Client: show retry time for login RespTooManyRequests
- Client: simplify registration RespTooManyRequests message to show
  retry time when provided
- Add test for retryAfterSecondsAt

* [Client] Restore registration guidance and drop duplicated rate limit reason

The registration RespTooManyRequests case now only shows the retry-after
copy when the server actually sent a denied_end_time; the per-email account
cap path (no end time) falls back to the previous guidance about already
having an account and contacting the server operator.

Drop the canned denied_reason_str from the new login rate limit branches so
the client no longer prints the same sentence twice.

* [Server] Exempt trusted_sources from auth rate limits

Addresses in security/trusted_sources bypass per-address limits, matching
the existing max_users_per_address bypass in initSession. Without this,
Webatrice behind local nginx (all clients as 127.0.0.1) would lock out
every web user after 5 mistyped passwords, and cap the whole server at
2 registrations / 3 forgot-password requests per window.

Adds Servatrice::isRateLimitExempt helper; recordFailedLogin,
isLoginRateLimited, recordRegistrationAttempt and
recordForgotPasswordAttempt all check it.

# Conflicts:
#	servatrice/src/servatrice.cpp

* [Server] Key login rate limit by (address, username)

A successful login cleared the whole per-IP failure history, so 4 bad
guesses plus 1 self-login (or guest UnknownUser login) from the same
address bypassed the limiter. Failures are now bucketed per
(address, lowercase username) and a success clears only its own pair.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-16 05:47:43 +02:00

124 lines
4.5 KiB
C++

#include "ratelimiter.h"
#include "gtest/gtest.h"
namespace
{
TEST(RateLimiterTest, AllowsAttemptsWithinLimit)
{
RateLimiter limiter;
ASSERT_FALSE(limiter.recordAttemptAt("ip", 3, 60, 1000));
ASSERT_FALSE(limiter.recordAttemptAt("ip", 3, 60, 1001));
// Still under the limit after two attempts, so the key is not blocked...
ASSERT_FALSE(limiter.isBlockedAt("ip", 3, 60, 1002));
// ...and the third (the limit) is allowed through.
ASSERT_FALSE(limiter.recordAttemptAt("ip", 3, 60, 1002));
// The next attempt is blocked once three are on the books.
ASSERT_TRUE(limiter.isBlockedAt("ip", 3, 60, 1003));
ASSERT_TRUE(limiter.recordAttemptAt("ip", 3, 60, 1003));
}
TEST(RateLimiterTest, BlocksAttemptsAtTheLimit)
{
RateLimiter limiter;
ASSERT_FALSE(limiter.recordAttemptAt("ip", 2, 60, 1000));
ASSERT_FALSE(limiter.recordAttemptAt("ip", 2, 60, 1001));
ASSERT_TRUE(limiter.recordAttemptAt("ip", 2, 60, 1002));
ASSERT_TRUE(limiter.isBlockedAt("ip", 2, 60, 1002));
}
TEST(RateLimiterTest, NonPositiveConfigDisablesLimiting)
{
RateLimiter limiter;
// maxAttempts = 0 is the documented "set to 0 to disable"; negative or
// zero windows are config mistakes and must not lock everyone out either.
ASSERT_FALSE(limiter.recordAttempt("ip", 0, 60));
ASSERT_FALSE(limiter.isBlocked("ip", 0, 60));
ASSERT_FALSE(limiter.recordAttempt("ip", -1, 60));
ASSERT_FALSE(limiter.isBlocked("ip", -1, 60));
ASSERT_FALSE(limiter.recordAttempt("ip", 5, 0));
ASSERT_FALSE(limiter.isBlocked("ip", 5, 0));
}
TEST(RateLimiterTest, ClearAttempts)
{
RateLimiter limiter;
ASSERT_FALSE(limiter.recordAttemptAt("ip", 1, 60, 1000));
ASSERT_TRUE(limiter.recordAttemptAt("ip", 1, 60, 1001));
ASSERT_TRUE(limiter.isBlockedAt("ip", 1, 60, 1001));
limiter.clearAttempts("ip");
ASSERT_FALSE(limiter.isBlockedAt("ip", 1, 60, 1002));
// The cleared key starts fresh and is allowed again.
ASSERT_FALSE(limiter.recordAttemptAt("ip", 1, 60, 1002));
}
TEST(RateLimiterTest, KeysAreIndependent)
{
RateLimiter limiter;
ASSERT_FALSE(limiter.recordAttemptAt("a", 1, 60, 1000));
ASSERT_TRUE(limiter.recordAttemptAt("a", 1, 60, 1001));
ASSERT_FALSE(limiter.isBlockedAt("b", 1, 60, 1001));
ASSERT_TRUE(limiter.isBlockedAt("a", 1, 60, 1001));
}
TEST(RateLimiterTest, AttemptsAgeOutOfTheWindow)
{
RateLimiter limiter;
const qint64 t = 1000;
const int window = 60;
ASSERT_FALSE(limiter.recordAttemptAt("ip", 2, window, t));
ASSERT_FALSE(limiter.recordAttemptAt("ip", 2, window, t + 1));
ASSERT_TRUE(limiter.recordAttemptAt("ip", 2, window, t + 2));
ASSERT_TRUE(limiter.isBlockedAt("ip", 2, window, t + 2));
// A blocked attempt is rejected without recording, so it cannot extend
// the lockout either.
ASSERT_TRUE(limiter.recordAttemptAt("ip", 2, window, t + 3));
// Once the recorded attempts fall outside the window the key clears by
// itself, with no manual unlock.
ASSERT_FALSE(limiter.isBlockedAt("ip", 2, window, t + 61));
ASSERT_FALSE(limiter.recordAttemptAt("ip", 2, window, t + 62));
}
TEST(RateLimiterTest, BlockedKeyIsUnlockedAfterSuccessfulClear)
{
RateLimiter limiter;
const qint64 t = 5000;
ASSERT_FALSE(limiter.recordAttemptAt("ip", 2, 60, t));
ASSERT_FALSE(limiter.recordAttemptAt("ip", 2, 60, t + 1));
ASSERT_TRUE(limiter.recordAttemptAt("ip", 2, 60, t + 2));
ASSERT_TRUE(limiter.isBlockedAt("ip", 2, 60, t + 2));
limiter.clearAttempts("ip");
ASSERT_FALSE(limiter.recordAttemptAt("ip", 2, 60, t + 3));
}
TEST(RateLimiterTest, RetryAfterSecondsReportsTimeUntilUnlock)
{
RateLimiter limiter;
const qint64 t = 1000;
const int window = 60;
ASSERT_EQ(0, limiter.retryAfterSecondsAt("ip", 2, window, t));
ASSERT_FALSE(limiter.recordAttemptAt("ip", 2, window, t));
ASSERT_EQ(0, limiter.retryAfterSecondsAt("ip", 2, window, t));
ASSERT_FALSE(limiter.recordAttemptAt("ip", 2, window, t + 1));
ASSERT_TRUE(limiter.isBlockedAt("ip", 2, window, t + 2));
// Oldest attempt was at t, window 60, so retry at t+60; now is t+2 => 58.
ASSERT_EQ(58, limiter.retryAfterSecondsAt("ip", 2, window, t + 2));
// At t+60 the window just elapsed; retry should return 0.
ASSERT_EQ(0, limiter.retryAfterSecondsAt("ip", 2, window, t + 60));
ASSERT_EQ(0, limiter.retryAfterSecondsAt("ip", 2, window, t + 61));
}
} // namespace
int main(int argc, char **argv)
{
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}