fix: review findings on the login throttle

- The throttle runs on a 64-bit clock in ms (esp_timer_get_time()), so a
  slot left alone for 49.7 days isn't brought back by millis() wrapping
  round to its old failure.
- Web.cpp turns the client into the throttle's key in one place, and
  says that an IPv6 client would share key 0; names are clearer
  (retryMs, vacantSlot).
- The shared slot's test now succeeds from the IP counted in it, so a
  success that cleared the shared slot would fail it.
- GLOSSARY.md names it the login throttle, with lockouts, and
  SECURITY_PLAN.md and ADR 0006 use that name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
cjkas 2026-10-27 18:44:39 +01:00
parent f8e97fb6dd
commit ff7517e10f
6 changed files with 53 additions and 30 deletions

View file

@ -13,8 +13,10 @@
// get the free failures on each: while the table is full, an IP it doesn't
// hold may have to wait even with the right credentials.
//
// The time is passed in (millis()), so native tests cover it. Only
// async_tcp's task calls it: both web servers run their handlers there.
// The time is passed in, in ms on a 64-bit clock (esp_timer_get_time() /
// 1000), so native tests cover it and a slot left alone for 49.7 days isn't
// woken by millis() wrapping. Only async_tcp's task calls it: both web
// servers run their handlers there.
#define LOGIN_FREE_FAILURES 5
#define LOGIN_FIRST_LOCKOUT_MS 30000UL
@ -26,20 +28,20 @@ struct login_attempts_t {
bool used = false;
uint32_t ip = 0;
uint16_t failures = 0;
uint32_t lastFailure = 0; // millis() of the last failure
uint64_t lastFailure = 0; // the time of the last failure
uint32_t lockoutMs = 0; // the lockout that failure began, 0 for none
};
class LoginThrottle {
public:
// How long ip must wait before it may try again, in ms; 0 when it may now.
uint32_t retryAfter(uint32_t ip, uint32_t now) const {
uint32_t retryAfter(uint32_t ip, uint64_t now) const {
const login_attempts_t *a = this->find(ip, now);
return a ? remaining(*a, now) : 0;
}
// A wrong PIN or password from ip. Returns the lockout it begins, in ms,
// or 0 while ip still has free failures.
uint32_t failed(uint32_t ip, uint32_t now) {
uint32_t failed(uint32_t ip, uint64_t now) {
login_attempts_t *a = this->claim(ip, now);
if(a->failures < UINT16_MAX) a->failures++;
a->lastFailure = now;
@ -63,18 +65,18 @@ class LoginThrottle {
login_attempts_t slots[LOGIN_THROTTLE_SLOTS];
login_attempts_t shared;
static uint32_t remaining(const login_attempts_t &a, uint32_t now) {
static uint32_t remaining(const login_attempts_t &a, uint64_t now) {
if(!a.used || a.lockoutMs == 0) return 0;
uint32_t elapsed = now - a.lastFailure;
return elapsed < a.lockoutMs ? a.lockoutMs - elapsed : 0;
uint64_t elapsed = now - a.lastFailure;
return elapsed < a.lockoutMs ? (uint32_t)(a.lockoutMs - elapsed) : 0;
}
// Unused, or its last failure is old enough to forget.
static bool vacant(const login_attempts_t &a, uint32_t now) {
static bool vacant(const login_attempts_t &a, uint64_t now) {
return !a.used || now - a.lastFailure >= LOGIN_FORGET_MS;
}
// ip's record: its own slot, nothing while a slot is vacant, else the
// shared one.
const login_attempts_t *find(uint32_t ip, uint32_t now) const {
const login_attempts_t *find(uint32_t ip, uint64_t now) const {
bool anyVacant = false;
for(const auto &a : this->slots) {
if(vacant(a, now)) anyVacant = true;
@ -84,13 +86,13 @@ class LoginThrottle {
return &this->shared;
}
// ip's record to count a failure in, taking a vacant slot when it has none.
login_attempts_t *claim(uint32_t ip, uint32_t now) {
login_attempts_t *free = nullptr;
login_attempts_t *claim(uint32_t ip, uint64_t now) {
login_attempts_t *vacantSlot = nullptr;
for(auto &a : this->slots) {
if(vacant(a, now)) { if(!free) free = &a; }
if(vacant(a, now)) { if(!vacantSlot) vacantSlot = &a; }
else if(a.ip == ip) return &a;
}
login_attempts_t *a = free ? free : &this->shared;
login_attempts_t *a = vacantSlot ? vacantSlot : &this->shared;
if(vacant(*a, now)) {
*a = login_attempts_t();
a->used = true;

View file

@ -2,6 +2,7 @@
#include <LittleFS.h>
#include <Update.h>
#include <esp_task_wdt.h>
#include <esp_timer.h>
#include "esp_log.h"
#include "mbedtls/md.h"
#include "ConfigSettings.h"
@ -52,6 +53,11 @@ static const char *TAG = "Web";
// Failed logins per client IP, on both web servers.
static LoginThrottle loginThrottle;
// The throttle's key for a client: its IPv4 address. The firmware doesn't
// enable IPv6; an IPv6 client would share key 0 with every other one.
static uint32_t loginKey(const IPAddress &ip) { return (uint32_t)ip; }
// The throttle's clock, in ms, which doesn't wrap as millis() does.
static uint64_t loginNow() { return (uint64_t)esp_timer_get_time() / 1000; }
// Hands an item to loop(), which owns the shades. When it can't be the
// request is answered with the reason and false is returned.
@ -341,9 +347,10 @@ void Web::handleLogin(AsyncWebServerRequest *request, JsonVariant &json) {
}
// A refused IP isn't told whether these credentials were right.
IPAddress ip = request->client()->remoteIP();
uint32_t wait = loginThrottle.retryAfter((uint32_t)ip, millis());
if(wait) {
uint32_t secs = (wait + 999) / 1000;
uint32_t key = loginKey(ip);
uint32_t retryMs = loginThrottle.retryAfter(key, loginNow());
if(retryMs) {
uint32_t secs = (retryMs + 999) / 1000;
snprintf(g_async_content, sizeof(g_async_content),
"{\"type\":%u,\"msg\":\"Too many failed logins. Try again in %lu s.\",\"success\":false,\"retryAfter\":%lu}",
static_cast<uint8_t>(settings.Security.type), (unsigned long)secs, (unsigned long)secs);
@ -380,14 +387,14 @@ void Web::handleLogin(AsyncWebServerRequest *request, JsonVariant &json) {
if(String(ptok) == String(token)) success = true;
}
if(success) {
loginThrottle.succeeded((uint32_t)ip);
loginThrottle.succeeded(key);
snprintf(g_async_content, sizeof(g_async_content),
"{\"type\":%u,\"apiKey\":\"%s\",\"msg\":\"Success\",\"success\":true}",
static_cast<uint8_t>(settings.Security.type), token);
request->send(200, _encoding_json, g_async_content);
}
else {
uint32_t lockout = loginThrottle.failed((uint32_t)ip, millis());
uint32_t lockout = loginThrottle.failed(key, loginNow());
if(lockout) ESP_LOGW(TAG, "Failed login from %s: refused for %lu s", ip.toString().c_str(), (unsigned long)(lockout / 1000));
snprintf(g_async_content, sizeof(g_async_content),
"{\"type\":%u,\"msg\":\"Invalid credentials\",\"success\":false}",