mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-09-27 00:14:40 -07:00
[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:
parent
1ed9823b56
commit
b2f63255f0
11 changed files with 238 additions and 3 deletions
|
|
@ -7,6 +7,7 @@ project(Servatrice VERSION "${PROJECT_VERSION_MAJOR}.${PROJECT_VERSION_MINOR}.${
|
|||
set(servatrice_SOURCES
|
||||
src/email_parser.cpp
|
||||
src/main.cpp
|
||||
src/ratelimiter.cpp
|
||||
src/servatrice.cpp
|
||||
src/servatrice_connection_pool.cpp
|
||||
src/servatrice_database_interface.cpp
|
||||
|
|
|
|||
|
|
@ -348,6 +348,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"
|
||||
|
|
|
|||
42
servatrice/src/ratelimiter.cpp
Normal file
42
servatrice/src/ratelimiter.cpp
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
#include "ratelimiter.h"
|
||||
|
||||
#include <QDateTime>
|
||||
|
||||
bool RateLimiter::recordAttempt(const QString &key, int maxAttempts, int windowSeconds)
|
||||
{
|
||||
QMutexLocker locker(&mutex);
|
||||
const qint64 now = QDateTime::currentSecsSinceEpoch();
|
||||
|
||||
QList<qint64> ×tamps = attempts[key];
|
||||
timestamps.append(now);
|
||||
while (!timestamps.isEmpty() && timestamps.first() <= now - windowSeconds) {
|
||||
timestamps.removeFirst();
|
||||
}
|
||||
|
||||
return timestamps.size() > maxAttempts;
|
||||
}
|
||||
|
||||
bool RateLimiter::isBlocked(const QString &key, int maxAttempts, int windowSeconds) const
|
||||
{
|
||||
QMutexLocker locker(&mutex);
|
||||
const qint64 now = QDateTime::currentSecsSinceEpoch();
|
||||
|
||||
const auto it = attempts.constFind(key);
|
||||
if (it == attempts.constEnd()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
int count = 0;
|
||||
for (const qint64 ×tamp : it.value()) {
|
||||
if (timestamp > now - windowSeconds) {
|
||||
++count;
|
||||
}
|
||||
}
|
||||
return count > maxAttempts;
|
||||
}
|
||||
|
||||
void RateLimiter::clearAttempts(const QString &key)
|
||||
{
|
||||
QMutexLocker locker(&mutex);
|
||||
attempts.remove(key);
|
||||
}
|
||||
25
servatrice/src/ratelimiter.h
Normal file
25
servatrice/src/ratelimiter.h
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
#ifndef RATELIMITER_H
|
||||
#define RATELIMITER_H
|
||||
|
||||
#include <QList>
|
||||
#include <QMap>
|
||||
#include <QMutex>
|
||||
#include <QString>
|
||||
|
||||
/** @brief Thread-safe per-key attempt counter used to throttle abusive requests. */
|
||||
class RateLimiter
|
||||
{
|
||||
public:
|
||||
/** @brief Record an attempt for the key and report whether the key is now over the limit. */
|
||||
bool recordAttempt(const QString &key, int maxAttempts, int windowSeconds);
|
||||
/** @brief True if the key already has more than maxAttempts attempts within windowSeconds. */
|
||||
bool isBlocked(const QString &key, int maxAttempts, int windowSeconds) const;
|
||||
/** @brief Drop all recorded attempts for the key, e.g. after a successful login. */
|
||||
void clearAttempts(const QString &key);
|
||||
|
||||
private:
|
||||
mutable QMutex mutex;
|
||||
QMap<QString, QList<qint64>> attempts; // key -> attempt timestamps (epoch seconds)
|
||||
};
|
||||
|
||||
#endif
|
||||
|
|
@ -1075,6 +1075,46 @@ int Servatrice::getForgotPasswordTokenLife() const
|
|||
return settingsCache->value("forgotpassword/tokenlife", 60).toInt();
|
||||
}
|
||||
|
||||
bool Servatrice::recordFailedLogin(const QString &ipAddress)
|
||||
{
|
||||
return rateLimiter.recordAttempt("login:" + ipAddress, getMaxLoginAttemptsPerIp(), getLoginAttemptWindowSeconds());
|
||||
}
|
||||
|
||||
void Servatrice::clearFailedLogins(const QString &ipAddress)
|
||||
{
|
||||
rateLimiter.clearAttempts("login:" + ipAddress);
|
||||
}
|
||||
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@
|
|||
#ifndef SERVATRICE_H
|
||||
#define SERVATRICE_H
|
||||
|
||||
#include "ratelimiter.h"
|
||||
|
||||
#include <QHostAddress>
|
||||
#include <QMetaType>
|
||||
#include <QMutex>
|
||||
|
|
@ -172,6 +174,8 @@ private:
|
|||
int nextShutdownMessageMinutes;
|
||||
QTimer *shutdownTimer;
|
||||
|
||||
RateLimiter rateLimiter;
|
||||
|
||||
mutable QMutex serverListMutex;
|
||||
QList<ServerProperties> serverList;
|
||||
void updateServerList();
|
||||
|
|
@ -275,6 +279,19 @@ public:
|
|||
void incRxBytes(quint64 num);
|
||||
void addDatabaseInterface(QThread *thread, Servatrice_DatabaseInterface *databaseInterface);
|
||||
|
||||
RateLimiter *getRateLimiter()
|
||||
{
|
||||
return &rateLimiter;
|
||||
}
|
||||
bool recordFailedLogin(const QString &ipAddress) override;
|
||||
void clearFailedLogins(const QString &ipAddress) override;
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -1433,9 +1433,8 @@ Response::ResponseCode AbstractServerSocketInterface::cmdRegisterAccount(const C
|
|||
|
||||
bool AbstractServerSocketInterface::tooManyRegistrationAttempts(const QString &ipAddress)
|
||||
{
|
||||
//! \todo Implement registration attempt limiting.
|
||||
Q_UNUSED(ipAddress);
|
||||
return false;
|
||||
return servatrice->getRateLimiter()->recordAttempt("register:" + ipAddress, servatrice->getMaxRegistrationsPerIp(),
|
||||
servatrice->getRegistrationWindowSeconds());
|
||||
}
|
||||
|
||||
Response::ResponseCode AbstractServerSocketInterface::cmdActivateAccount(const Command_Activate &cmd,
|
||||
|
|
@ -1788,6 +1787,17 @@ Response::ResponseCode AbstractServerSocketInterface::cmdForgotPasswordRequest(c
|
|||
|
||||
qCDebug(AbstractServerSocketInterfaceLog) << "Received reset password request from user:" << userName;
|
||||
|
||||
if (servatrice->getRateLimiter()->recordAttempt("forgot:" + this->getAddress(),
|
||||
servatrice->getMaxForgotPasswordRequestsPerIp(),
|
||||
servatrice->getForgotPasswordWindowSeconds())) {
|
||||
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(),
|
||||
|
|
@ -1929,6 +1939,16 @@ AbstractServerSocketInterface::cmdForgotPasswordChallenge(const Command_ForgotPa
|
|||
|
||||
qCDebug(AbstractServerSocketInterfaceLog) << "Received reset password challenge from user:" << userName;
|
||||
|
||||
if (servatrice->getRateLimiter()->recordAttempt("forgot:" + this->getAddress(),
|
||||
servatrice->getMaxForgotPasswordRequestsPerIp(),
|
||||
servatrice->getForgotPasswordWindowSeconds())) {
|
||||
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(),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue