[Server] Inform client of login/registration rate limit timeout (#7468)
Some checks are pending
CodeQL / Analyze (cpp) (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
Build Desktop / Configure (push) Waiting to run
Build Desktop / Debian 13 (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Fedora 43 (push) Blocked by required conditions
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 cover (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (push) Blocked by required conditions
Build Desktop / macOS 14 (push) Blocked by required conditions
Build Desktop / macOS 15 (push) Blocked by required conditions
Build Desktop / macOS 26 Debug (push) Blocked by required conditions
Build Desktop / Windows 10 (push) Blocked by required conditions
Build Desktop / Prune caches (push) Waiting to run
Build Docker / Servatrice (arm) (push) Waiting to run
Build Docker / Servatrice (x86) (push) Waiting to run
Build Docker / Publish multi-platform Servatrice image (push) Blocked by required conditions

* [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>
This commit is contained in:
BruebachL 2026-10-16 05:47:43 +02:00 • committed by GitHub
parent 213ce691dd
commit 456d9d9e70
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 600 additions and 9 deletions

View file

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

View file

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

View file

@ -0,0 +1,125 @@
#include "ratelimiter.h"
#include <QDateTime>
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<qint64> &timestamps = 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 &timestamp : 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<qint64> 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<int>(retryAt - now);
}
void RateLimiter::pruneLocked(qint64 now)
{
if (lastPruneSecs + PruneIntervalSeconds > now) {
return;
}
lastPruneSecs = now;
auto it = attempts.begin();
while (it != attempts.end()) {
QList<qint64> &timestamps = it.value().timestamps;
while (!timestamps.isEmpty() && timestamps.first() <= now - it.value().windowSeconds) {
timestamps.removeFirst();
}
if (timestamps.isEmpty()) {
it = attempts.erase(it);
} else {
++it;
}
}
}

View file

@ -0,0 +1,93 @@
#ifndef RATELIMITER_H
#define RATELIMITER_H
#include <QList>
#include <QMap>
#include <QMutex>
#include <QString>
/** @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<qint64> 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<QString, AttemptHistory> attempts; // key -> attempt history
qint64 lastPruneSecs = 0;
static constexpr int PruneIntervalSeconds = 60;
};
#endif

View file

@ -51,6 +51,7 @@
#include <QSsl>
#include <QString>
#include <QStringList>
#include <QStringLiteral>
#include <QThread>
#include <QTimer>
#include <QTypeInfo>
@ -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();

View file

@ -21,6 +21,7 @@
#define SERVATRICE_H
#include "metrics_registry.h"
#include "ratelimiter.h"
#include "smtpclient.h"
#include <QByteArray>
@ -204,6 +205,8 @@ private:
std::shared_ptr<const Response_ReportStats> reportStatsCache;
static constexpr int reportStatsCacheTtlSeconds = 60;
RateLimiter rateLimiter;
mutable QMutex serverListMutex;
QList<ServerProperties> 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);

View file

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