[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 @@ 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

View file

@ -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"

View 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> &timestamps = 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 &timestamp : it.value()) {
if (timestamp > now - windowSeconds) {
++count;
}
}
return count > maxAttempts;
}
void RateLimiter::clearAttempts(const QString &key)
{
QMutexLocker locker(&mutex);
attempts.remove(key);
}

View 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

View file

@ -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();

View file

@ -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);

View file

@ -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(),