diff --git a/cockatrice/src/client/network/connection_controller/remote_connection_controller.cpp b/cockatrice/src/client/network/connection_controller/remote_connection_controller.cpp index 7480da27e..9b985073d 100644 --- a/cockatrice/src/client/network/connection_controller/remote_connection_controller.cpp +++ b/cockatrice/src/client/network/connection_controller/remote_connection_controller.cpp @@ -239,6 +239,17 @@ void ConnectionController::onLoginError(int r, break; } + case Response::RespTooManyRequests: { + QString bannedStr = endTime ? tr("Too many login attempts. Try again after %1.") + .arg(QDateTime::fromSecsSinceEpoch(endTime).toString()) + : tr("Too many login attempts. Please try again later."); + if (!reasonStr.isEmpty()) { + bannedStr.append("\n\n" + reasonStr); + } + QMessageBox::critical(dialogParent, tr("Error"), bannedStr); + break; + } + case Response::RespWrongPassword: { QMessageBox::critical(dialogParent, tr("Error"), tr("Incorrect username or password. " @@ -365,12 +376,15 @@ void ConnectionController::onRegisterError(int r, QString reasonStr, quint32 end break; } case Response::RespTooManyRequests: { - QMessageBox::critical(dialogParent, tr("Registration denied"), - tr("It appears you are attempting to register a new account on this server yet you " - "already have an account registered with the email provided. This server " - "restricts the number of accounts a user can register per address. Please " - "contact the server operator for further assistance or to obtain your " - "credential information.")); + QString deniedStr = + endTime ? tr("Too many registration attempts. Try again after %1.") + .arg(QDateTime::fromSecsSinceEpoch(endTime).toString()) + : tr("It appears you are attempting to register a new account on this server yet you " + "already have an account registered with the email provided. This server " + "restricts the number of accounts a user can register per address. Please " + "contact the server operator for further assistance or to obtain your " + "credential information."); + QMessageBox::critical(dialogParent, tr("Registration denied"), deniedStr); break; } case Response::RespPasswordTooShort: { diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server.h b/libcockatrice_network/libcockatrice/network/server/remote/server.h index 99247bb67..932a136ff 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/server.h @@ -187,6 +187,39 @@ public: virtual void observeGameStartDurationMs(qint64 /* elapsedMs */) { } + /** + * @brief Record a failed login attempt for the (address, username) pair; returns true if now locked out. + * + * Keyed by pair so a successful login as one user cannot wipe out another + * user's failure history from the same address (e.g. a guest login or the + * attacker's own account clearing the way for further guessing). + */ + virtual bool recordFailedLogin(const QString & /*ipAddress*/, const QString & /*userName*/) + { + return false; + } + /** + * @brief True if the (address, username) pair is already locked out of logging in. + * + * Consulted before any authentication work (database round trip, password + * verification) so a blocked pair cannot burn server CPU per attempt. + */ + virtual bool isLoginRateLimited(const QString & /*ipAddress*/, const QString & /*userName*/) + { + return false; + } + /** + * @brief Seconds remaining before the login rate limit for the (address, username) pair lifts. + * Returns 0 if not limited. + */ + virtual int loginRateLimitRemainingSeconds(const QString & /*ipAddress*/, const QString & /*userName*/) + { + return 0; + } + /** @brief Clear any failed-login lockout for the (address, username) pair, e.g. after a successful login. */ + virtual void clearFailedLogins(const QString & /*ipAddress*/, const QString & /*userName*/) + { + } Server_DatabaseInterface *getDatabaseInterface() const; int getNextLocalGameId() diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp index fc5cfa148..fbca9589a 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp @@ -592,6 +592,17 @@ Response::ResponseCode Server_ProtocolHandler::cmdLogin(const Command_Login &cmd QString reasonStr; int banSecondsLeft = 0; QString connectionType = getConnectionType(); + // Throttle before doing any work: a locked-out address must not get a full + // database round trip and password verification on every attempt. + if (server->isLoginRateLimited(getAddress(), userName)) { + int remaining = server->loginRateLimitRemainingSeconds(getAddress(), userName); + if (remaining > 0) { + auto *re = new Response_Login; + re->set_denied_end_time(QDateTime::currentDateTime().addSecs(remaining).toSecsSinceEpoch()); + rc.setResponseExtension(re); + } + return Response::RespTooManyRequests; + } AuthenticationResult res = server->loginUser(this, userName, password, needsHash, reasonStr, banSecondsLeft, clientId, clientVersion, connectionType); switch (res) { @@ -605,10 +616,20 @@ Response::ResponseCode Server_ProtocolHandler::cmdLogin(const Command_Login &cmd return Response::RespUserIsBanned; } case NotLoggedIn: + if (server->recordFailedLogin(getAddress(), userName)) { + int remaining = server->loginRateLimitRemainingSeconds(getAddress(), userName); + if (remaining > 0) { + auto *re = new Response_Login; + re->set_denied_end_time(QDateTime::currentDateTime().addSecs(remaining).toSecsSinceEpoch()); + rc.setResponseExtension(re); + } + return Response::RespTooManyRequests; + } return Response::RespWrongPassword; case WouldOverwriteOldSession: return Response::RespWouldOverwriteOldSession; case UsernameInvalid: { + server->recordFailedLogin(getAddress(), userName); auto *re = new Response_Login; re->set_denied_reason_str(reasonStr.toStdString()); rc.setResponseExtension(re); @@ -619,10 +640,12 @@ Response::ResponseCode Server_ProtocolHandler::cmdLogin(const Command_Login &cmd case ClientIdRequired: return Response::RespClientIdRequired; case UserIsInactive: + server->recordFailedLogin(getAddress(), userName); return Response::RespAccountNotActivated; case PasswordChangeRequired: return Response::RespPasswordChangeRequired; default: + server->clearFailedLogins(getAddress(), userName); authState = res; usingRealPassword = needsHash; } diff --git a/servatrice/CMakeLists.txt b/servatrice/CMakeLists.txt index b45608d86..f6d28d6c1 100644 --- a/servatrice/CMakeLists.txt +++ b/servatrice/CMakeLists.txt @@ -9,6 +9,7 @@ set(servatrice_SOURCES src/event_loop_watchdog.cpp src/main.cpp src/metrics_registry.cpp + src/ratelimiter.cpp src/servatrice.cpp src/servatrice_connection_pool.cpp src/servatrice_database_interface.cpp diff --git a/servatrice/servatrice.ini.example b/servatrice/servatrice.ini.example index 3ee52dd21..f802f0244 100644 --- a/servatrice/servatrice.ini.example +++ b/servatrice/servatrice.ini.example @@ -366,6 +366,27 @@ max_users_websocket=500 ; Maximum number of users that can connect from the same IP address; useful to avoid bots, default is 4 max_users_per_address=4 +; Maximum number of failed login attempts from a single IP address before that +; address is temporarily locked out. Default is 5; set to 0 to disable. +max_login_attempts_per_ip=5 + +; Length in seconds of the sliding window used for the login lockout. Default is 900 (15 minutes). +login_attempt_window_seconds=900 + +; Maximum number of account registrations from a single IP address within the window below. +; Default is 2; set to 0 to disable. +max_registrations_per_ip=2 + +; Length in seconds of the registration window. Default is 3600 (1 hour). +registration_window_seconds=3600 + +; Maximum number of forgot-password requests from a single IP address within the window below. +; Default is 3; set to 0 to disable. +max_forgot_password_requests_per_ip=3 + +; Length in seconds of the forgot-password window. Default is 3600 (1 hour). +forgot_password_window_seconds=3600 + ; You may want to allow an unlimited number of users from a trusted source. This setting can contain a ; comma-separed list of IP addresses which will allow an unlimited number of connections from each of the ; IP addresses listed (ignoring the max_users_per_address). Default is "127.0.0.1,::1"; example: "192.73.233.244,81.4.100.74" diff --git a/servatrice/src/ratelimiter.cpp b/servatrice/src/ratelimiter.cpp new file mode 100644 index 000000000..e7f209e38 --- /dev/null +++ b/servatrice/src/ratelimiter.cpp @@ -0,0 +1,125 @@ +#include "ratelimiter.h" + +#include + +bool RateLimiter::recordAttempt(const QString &key, int maxAttempts, int windowSeconds) +{ + return recordAttemptAt(key, maxAttempts, windowSeconds, QDateTime::currentSecsSinceEpoch()); +} + +bool RateLimiter::recordAttemptAt(const QString &key, int maxAttempts, int windowSeconds, qint64 now) +{ + if (maxAttempts <= 0 || windowSeconds <= 0) { + return false; // rate limiting disabled for this endpoint + } + + QMutexLocker locker(&mutex); + pruneLocked(now); + + AttemptHistory &history = attempts[key]; + history.windowSeconds = windowSeconds; + QList ×tamps = history.timestamps; + while (!timestamps.isEmpty() && timestamps.first() <= now - windowSeconds) { + timestamps.removeFirst(); + } + if (timestamps.size() >= maxAttempts) { + // Already at the limit: reject without appending so repeated attempts + // at the attacker's own pace cannot keep sliding the window forward + // and hold the lockout open forever. + return true; + } + + timestamps.append(now); + return false; +} + +bool RateLimiter::isBlocked(const QString &key, int maxAttempts, int windowSeconds) const +{ + return isBlockedAt(key, maxAttempts, windowSeconds, QDateTime::currentSecsSinceEpoch()); +} + +bool RateLimiter::isBlockedAt(const QString &key, int maxAttempts, int windowSeconds, qint64 now) const +{ + if (maxAttempts <= 0 || windowSeconds <= 0) { + return false; + } + + QMutexLocker locker(&mutex); + + const auto it = attempts.constFind(key); + if (it == attempts.constEnd()) { + return false; + } + + int count = 0; + for (const qint64 ×tamp : it.value().timestamps) { + if (timestamp > now - windowSeconds) { + ++count; + } + } + return count >= maxAttempts; +} + +void RateLimiter::clearAttempts(const QString &key) +{ + QMutexLocker locker(&mutex); + attempts.remove(key); +} + +int RateLimiter::retryAfterSeconds(const QString &key, int maxAttempts, int windowSeconds) const +{ + return retryAfterSecondsAt(key, maxAttempts, windowSeconds, QDateTime::currentSecsSinceEpoch()); +} + +int RateLimiter::retryAfterSecondsAt(const QString &key, int maxAttempts, int windowSeconds, qint64 now) const +{ + if (maxAttempts <= 0 || windowSeconds <= 0) { + return 0; + } + + QMutexLocker locker(&mutex); + + const auto it = attempts.constFind(key); + if (it == attempts.constEnd()) { + return 0; + } + + QList timestamps = it.value().timestamps; + while (!timestamps.isEmpty() && timestamps.first() <= now - windowSeconds) { + timestamps.removeFirst(); + } + if (timestamps.size() < maxAttempts) { + return 0; + } + if (timestamps.isEmpty()) { + return 0; + } + + qint64 oldest = timestamps.first(); + qint64 retryAt = oldest + windowSeconds; + if (retryAt <= now) { + return 0; + } + return static_cast(retryAt - now); +} + +void RateLimiter::pruneLocked(qint64 now) +{ + if (lastPruneSecs + PruneIntervalSeconds > now) { + return; + } + lastPruneSecs = now; + + auto it = attempts.begin(); + while (it != attempts.end()) { + QList ×tamps = it.value().timestamps; + while (!timestamps.isEmpty() && timestamps.first() <= now - it.value().windowSeconds) { + timestamps.removeFirst(); + } + if (timestamps.isEmpty()) { + it = attempts.erase(it); + } else { + ++it; + } + } +} diff --git a/servatrice/src/ratelimiter.h b/servatrice/src/ratelimiter.h new file mode 100644 index 000000000..e2bc8fab6 --- /dev/null +++ b/servatrice/src/ratelimiter.h @@ -0,0 +1,93 @@ +#ifndef RATELIMITER_H +#define RATELIMITER_H + +#include +#include +#include +#include + +/** @brief Thread-safe per-key attempt limiter used to throttle abusive requests. */ +class RateLimiter +{ +public: + /** + * @brief Record an attempt for @p key and report whether the key is now blocked. + * + * A key that is already at its limit is not extended: the attempt is + * rejected without appending, so the lockout lifts once the recorded + * attempts age out of the window. Returns false immediately when + * @p maxAttempts <= 0 or @p windowSeconds <= 0 (rate limiting disabled), + * recording nothing. + * + * Delegates to recordAttemptAt() with the current wall-clock time. + */ + bool recordAttempt(const QString &key, int maxAttempts, int windowSeconds); + + /** + * @brief Record an attempt as seen at epoch second @p now. + * + * Exposed for deterministic tests; production callers should use + * recordAttempt(). + */ + bool recordAttemptAt(const QString &key, int maxAttempts, int windowSeconds, qint64 now); + + /** + * @brief True if @p key already has at least @p maxAttempts attempts within @p windowSeconds. + * + * Reads only; records nothing. Returns false when @p maxAttempts <= 0 or + * @p windowSeconds <= 0. Delegates to isBlockedAt(). + */ + bool isBlocked(const QString &key, int maxAttempts, int windowSeconds) const; + + /** + * @brief Whether @p key is blocked as measured at epoch second @p now. + * + * Exposed for deterministic tests; production callers should use + * isBlocked(). + */ + bool isBlockedAt(const QString &key, int maxAttempts, int windowSeconds, qint64 now) const; + + /** @brief Drop all recorded attempts for the key, e.g. after a successful login. */ + void clearAttempts(const QString &key); + + /** + * @brief Get the retry-after window in seconds for @p key. + * + * Returns the number of seconds from @p now until the oldest attempt + * exits @p windowSeconds and the key is no longer blocked. If the key is + * not blocked, returns 0. Returns 0 if @p maxAttempts <= 0 or + * @p windowSeconds <= 0. + * + * Delegates to retryAfterSecondsAt(). + */ + int retryAfterSeconds(const QString &key, int maxAttempts, int windowSeconds) const; + + /** + * @brief Get the retry-after window in seconds for @p key as measured at @p now. + * + * Exposed for deterministic tests. + */ + int retryAfterSecondsAt(const QString &key, int maxAttempts, int windowSeconds, qint64 now) const; + +private: + struct AttemptHistory + { + QList timestamps; // epoch seconds, oldest first + int windowSeconds; + }; + + /** + * @brief Drops keys whose last attempts have fully aged out of their window. + * + * Runs at most every PruneIntervalSeconds to stop the map from growing + * without bound when attackers rotate source addresses. + */ + void pruneLocked(qint64 now); + + mutable QMutex mutex; + QMap attempts; // key -> attempt history + qint64 lastPruneSecs = 0; + static constexpr int PruneIntervalSeconds = 60; +}; + +#endif diff --git a/servatrice/src/servatrice.cpp b/servatrice/src/servatrice.cpp index 9ec9ef5bc..a832c1652 100644 --- a/servatrice/src/servatrice.cpp +++ b/servatrice/src/servatrice.cpp @@ -51,6 +51,7 @@ #include #include #include +#include #include #include #include @@ -1286,6 +1287,105 @@ int Servatrice::getForgotPasswordTokenLife() const return settingsCache->value("forgotpassword/tokenlife", 60).toInt(); } +/** + * @brief Rate limiter key for one login target. + * + * Keyed by (address, username) so a success only clears that pair's history: + * a guest login or a login as the attacker's own account from the same + * address must not wipe out another user's failure count. Lowercased so + * case variations of the same name share one bucket. + */ +static QString loginLimitKey(const QString &ipAddress, const QString &userName) +{ + return QStringLiteral("login:") + ipAddress + QStringLiteral(":") + userName.toLower(); +} + +bool Servatrice::recordFailedLogin(const QString &ipAddress, const QString &userName) +{ + if (isRateLimitExempt(ipAddress)) { + return false; + } + return rateLimiter.recordAttempt(loginLimitKey(ipAddress, userName), getMaxLoginAttemptsPerIp(), + getLoginAttemptWindowSeconds()); +} + +bool Servatrice::isLoginRateLimited(const QString &ipAddress, const QString &userName) +{ + if (isRateLimitExempt(ipAddress)) { + return false; + } + return rateLimiter.isBlocked(loginLimitKey(ipAddress, userName), getMaxLoginAttemptsPerIp(), + getLoginAttemptWindowSeconds()); +} + +int Servatrice::loginRateLimitRemainingSeconds(const QString &ipAddress, const QString &userName) +{ + return rateLimiter.retryAfterSeconds(loginLimitKey(ipAddress, userName), getMaxLoginAttemptsPerIp(), + getLoginAttemptWindowSeconds()); +} + +bool Servatrice::recordRegistrationAttempt(const QString &ipAddress) +{ + if (isRateLimitExempt(ipAddress)) { + return false; + } + return rateLimiter.recordAttempt("register:" + ipAddress, getMaxRegistrationsPerIp(), + getRegistrationWindowSeconds()); +} + +bool Servatrice::recordForgotPasswordAttempt(const QString &ipAddress) +{ + if (isRateLimitExempt(ipAddress)) { + return false; + } + return rateLimiter.recordAttempt("forgot:" + ipAddress, getMaxForgotPasswordRequestsPerIp(), + getForgotPasswordWindowSeconds()); +} + +bool Servatrice::isRateLimitExempt(const QString &ipAddress) const +{ + // Mirrors the max_users_per_address bypass in initSession: trusted sources + // (e.g. 127.0.0.1 for Webatrice behind local nginx, where every websocket + // client shares one address) are not subject to per-address limits. + const QString trustedSources = settingsCache->value("security/trusted_sources", "127.0.0.1,::1").toString(); + return trustedSources.contains(ipAddress, Qt::CaseInsensitive); +} + +void Servatrice::clearFailedLogins(const QString &ipAddress, const QString &userName) +{ + rateLimiter.clearAttempts(loginLimitKey(ipAddress, userName)); +} + +int Servatrice::getMaxLoginAttemptsPerIp() const +{ + return settingsCache->value("security/max_login_attempts_per_ip", 5).toInt(); +} + +int Servatrice::getLoginAttemptWindowSeconds() const +{ + return settingsCache->value("security/login_attempt_window_seconds", 900).toInt(); +} + +int Servatrice::getMaxRegistrationsPerIp() const +{ + return settingsCache->value("security/max_registrations_per_ip", 2).toInt(); +} + +int Servatrice::getRegistrationWindowSeconds() const +{ + return settingsCache->value("security/registration_window_seconds", 3600).toInt(); +} + +int Servatrice::getMaxForgotPasswordRequestsPerIp() const +{ + return settingsCache->value("security/max_forgot_password_requests_per_ip", 3).toInt(); +} + +int Servatrice::getForgotPasswordWindowSeconds() const +{ + return settingsCache->value("security/forgot_password_window_seconds", 3600).toInt(); +} + bool Servatrice::getEnableForgotPasswordChallenge() const { return settingsCache->value("forgotpassword/enablechallenge", false).toBool(); diff --git a/servatrice/src/servatrice.h b/servatrice/src/servatrice.h index 205a4f04d..d5414f810 100644 --- a/servatrice/src/servatrice.h +++ b/servatrice/src/servatrice.h @@ -21,6 +21,7 @@ #define SERVATRICE_H #include "metrics_registry.h" +#include "ratelimiter.h" #include "smtpclient.h" #include @@ -204,6 +205,8 @@ private: std::shared_ptr reportStatsCache; static constexpr int reportStatsCacheTtlSeconds = 60; + RateLimiter rateLimiter; + mutable QMutex serverListMutex; QList serverList; void updateServerList(); @@ -362,6 +365,25 @@ public: */ void watchWorkerThread(QThread *thread); + RateLimiter *getRateLimiter() + { + return &rateLimiter; + } + bool recordFailedLogin(const QString &ipAddress, const QString &userName) override; + bool isLoginRateLimited(const QString &ipAddress, const QString &userName) override; + int loginRateLimitRemainingSeconds(const QString &ipAddress, const QString &userName) override; + void clearFailedLogins(const QString &ipAddress, const QString &userName) override; + bool recordRegistrationAttempt(const QString &ipAddress); + bool recordForgotPasswordAttempt(const QString &ipAddress); + /// True when the address is in security/trusted_sources and therefore exempt from per-address limits. + bool isRateLimitExempt(const QString &ipAddress) const; + int getMaxLoginAttemptsPerIp() const; + int getLoginAttemptWindowSeconds() const; + int getMaxRegistrationsPerIp() const; + int getRegistrationWindowSeconds() const; + int getMaxForgotPasswordRequestsPerIp() const; + int getForgotPasswordWindowSeconds() const; + bool islConnectionExists(int _serverId) const; void addIslInterface(int _serverId, IslInterface *interface); void removeIslInterface(int _serverId); diff --git a/servatrice/src/serversocketinterface.cpp b/servatrice/src/serversocketinterface.cpp index 5f5d1ced5..bf8d02fc7 100644 --- a/servatrice/src/serversocketinterface.cpp +++ b/servatrice/src/serversocketinterface.cpp @@ -3263,6 +3263,15 @@ Response::ResponseCode AbstractServerSocketInterface::cmdRegisterAccount(const C false); } + Response_Register *re = new Response_Register; + re->set_denied_reason_str("Too many registration attempts"); + int remaining = servatrice->getRateLimiter()->retryAfterSeconds("register:" + this->getAddress(), + servatrice->getMaxRegistrationsPerIp(), + servatrice->getRegistrationWindowSeconds()); + if (remaining > 0) { + re->set_denied_end_time(QDateTime::currentDateTime().addSecs(remaining).toSecsSinceEpoch()); + } + rc.setResponseExtension(re); return Response::RespTooManyRequests; } @@ -3330,9 +3339,7 @@ Response::ResponseCode AbstractServerSocketInterface::cmdRegisterAccount(const C bool AbstractServerSocketInterface::tooManyRegistrationAttempts(const QString &ipAddress) { - //! \todo Implement registration attempt limiting. - Q_UNUSED(ipAddress); - return false; + return servatrice->recordRegistrationAttempt(ipAddress); } Response::ResponseCode AbstractServerSocketInterface::cmdActivateAccount(const Command_Activate &cmd, @@ -3687,6 +3694,15 @@ Response::ResponseCode AbstractServerSocketInterface::cmdForgotPasswordRequest(c qCDebug(AbstractServerSocketInterfaceLog) << "Received reset password request from user:" << userName; + if (servatrice->recordForgotPasswordAttempt(this->getAddress())) { + if (servatrice->getEnableForgotPasswordAudit()) { + sqlInterface->addAuditRecord(userName.simplified(), this->getAddress(), clientId.simplified(), + "PASSWORD_RESET_REQUEST", "Too many requests from this ip address", false); + } + + return Response::RespTooManyRequests; + } + if (!servatrice->getEnableForgotPassword()) { if (servatrice->getEnableForgotPasswordAudit()) { sqlInterface->addAuditRecord(userName.simplified(), this->getAddress(), clientId.simplified(), @@ -3829,6 +3845,14 @@ AbstractServerSocketInterface::cmdForgotPasswordChallenge(const Command_ForgotPa qCDebug(AbstractServerSocketInterfaceLog) << "Received reset password challenge from user:" << userName; + if (servatrice->recordForgotPasswordAttempt(this->getAddress())) { + if (servatrice->getEnableForgotPasswordAudit()) { + sqlInterface->addAuditRecord(userName.simplified(), this->getAddress(), clientId.simplified(), + "PASSWORD_RESET_CHALLENGE", "Too many requests from this ip address", false); + } + return Response::RespTooManyRequests; + } + if (!servatrice->getEnableForgotPasswordChallenge()) { if (servatrice->getEnableForgotPasswordAudit()) { sqlInterface->addAuditRecord(userName.simplified(), this->getAddress(), clientId.simplified(), diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 9955e1189..784aac3be 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -196,6 +196,17 @@ cockatrice_add_test( LABELS server ) +cockatrice_add_test( + NAME + rate_limiter_test + SOURCES + ${CMAKE_SOURCE_DIR}/servatrice/src/ratelimiter.cpp + rate_limiter_test.cpp + INCLUDE_DIRS + ${CMAKE_SOURCE_DIR}/servatrice/src + LABELS + server +) cockatrice_add_test( NAME server_card_counter_test diff --git a/tests/rate_limiter_test.cpp b/tests/rate_limiter_test.cpp new file mode 100644 index 000000000..b1fb995f5 --- /dev/null +++ b/tests/rate_limiter_test.cpp @@ -0,0 +1,124 @@ +#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(); +} \ No newline at end of file