mirror of
https://github.com/rstrouse/ESPSomfy-RTS.git
synced 2026-10-31 13:52:15 +01:00
feat: throttle failed logins per client IP
PR C of SECURITY_PLAN.md: after 5 failed /login attempts an IP is refused for 30 s, and each failure after that refuses it for twice as long, up to 15 min. A success resets it, as does an hour without a failure. - LoginThrottle.h holds the schedule and 8 IPs, with the time passed in so native tests cover it. While all 8 hold recent failures, every other IP shares one more slot, so many addresses don't each get five free tries. - A refused IP gets 429 with Retry-After before its credentials are checked, so a right guess during a lockout reveals nothing. Home Assistant retries a 429 instead of asking for new credentials. - The web UI's login panel shows a 401's or 429's message, where it popped a service error. - test/e2e --lockout checks it on a Secured device and waits it out. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
1612fd1587
commit
bab83313ee
8 changed files with 326 additions and 1 deletions
102
src/LoginThrottle.h
Normal file
102
src/LoginThrottle.h
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
#ifndef loginthrottle_h
|
||||
#define loginthrottle_h
|
||||
#include <stdint.h>
|
||||
|
||||
// Failed /login attempts per client IP (SECURITY_PLAN.md, PR C). The first
|
||||
// LOGIN_FREE_FAILURES failures cost nothing, and the last of them locks the
|
||||
// IP out for LOGIN_FIRST_LOCKOUT_MS. After that every failure locks it out
|
||||
// again, for twice as long as the lockout before, up to LOGIN_MAX_LOCKOUT_MS.
|
||||
// A success forgets the IP, and so does LOGIN_FORGET_MS without a failure.
|
||||
//
|
||||
// It keeps LOGIN_THROTTLE_SLOTS IPs. Once each holds a recent failure, every
|
||||
// other IP shares one more slot, so an attacker with many addresses can't
|
||||
// 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.
|
||||
|
||||
#define LOGIN_FREE_FAILURES 5
|
||||
#define LOGIN_FIRST_LOCKOUT_MS 30000UL
|
||||
#define LOGIN_MAX_LOCKOUT_MS 900000UL
|
||||
#define LOGIN_FORGET_MS 3600000UL
|
||||
#define LOGIN_THROTTLE_SLOTS 8
|
||||
|
||||
struct login_attempts_t {
|
||||
bool used = false;
|
||||
uint32_t ip = 0;
|
||||
uint16_t failures = 0;
|
||||
uint32_t lastFailure = 0; // millis() 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 {
|
||||
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) {
|
||||
login_attempts_t *a = this->claim(ip, now);
|
||||
if(a->failures < UINT16_MAX) a->failures++;
|
||||
a->lastFailure = now;
|
||||
a->lockoutMs = lockoutFor(a->failures);
|
||||
return a->lockoutMs;
|
||||
}
|
||||
// The right credentials from ip. The shared slot is left as it is: one
|
||||
// user's success says nothing about the other addresses in it.
|
||||
void succeeded(uint32_t ip) {
|
||||
for(auto &a : this->slots)
|
||||
if(a.used && a.ip == ip) a = login_attempts_t();
|
||||
}
|
||||
// The lockout the failures-th failure in a row begins.
|
||||
static uint32_t lockoutFor(uint16_t failures) {
|
||||
if(failures < LOGIN_FREE_FAILURES) return 0;
|
||||
uint32_t ms = LOGIN_FIRST_LOCKOUT_MS;
|
||||
for(uint16_t i = LOGIN_FREE_FAILURES; i < failures && ms < LOGIN_MAX_LOCKOUT_MS; i++) ms *= 2;
|
||||
return ms < LOGIN_MAX_LOCKOUT_MS ? ms : LOGIN_MAX_LOCKOUT_MS;
|
||||
}
|
||||
private:
|
||||
login_attempts_t slots[LOGIN_THROTTLE_SLOTS];
|
||||
login_attempts_t shared;
|
||||
|
||||
static uint32_t remaining(const login_attempts_t &a, uint32_t now) {
|
||||
if(!a.used || a.lockoutMs == 0) return 0;
|
||||
uint32_t elapsed = now - a.lastFailure;
|
||||
return elapsed < a.lockoutMs ? 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) {
|
||||
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 {
|
||||
bool anyVacant = false;
|
||||
for(const auto &a : this->slots) {
|
||||
if(vacant(a, now)) anyVacant = true;
|
||||
else if(a.ip == ip) return &a;
|
||||
}
|
||||
if(anyVacant || vacant(this->shared, now)) return nullptr;
|
||||
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;
|
||||
for(auto &a : this->slots) {
|
||||
if(vacant(a, now)) { if(!free) free = &a; }
|
||||
else if(a.ip == ip) return &a;
|
||||
}
|
||||
login_attempts_t *a = free ? free : &this->shared;
|
||||
if(vacant(*a, now)) {
|
||||
*a = login_attempts_t();
|
||||
a->used = true;
|
||||
a->ip = ip;
|
||||
}
|
||||
return a;
|
||||
}
|
||||
};
|
||||
#endif
|
||||
|
|
@ -20,6 +20,7 @@
|
|||
#include "MqttConnection.h"
|
||||
#include "ESPNetwork.h"
|
||||
#include "Timing.h"
|
||||
#include "LoginThrottle.h"
|
||||
#include <AsyncTCP.h>
|
||||
#include <ESPAsyncWebServer.h>
|
||||
#include <AsyncJson.h>
|
||||
|
|
@ -49,6 +50,9 @@ const char _encoding_json[] = "application/json";
|
|||
|
||||
static const char *TAG = "Web";
|
||||
|
||||
// Failed logins per client IP, on both web servers.
|
||||
static LoginThrottle loginThrottle;
|
||||
|
||||
// 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.
|
||||
bool queueLoopItem(AsyncWebServerRequest *request, const loop_item_t &item) {
|
||||
|
|
@ -335,6 +339,19 @@ void Web::handleLogin(AsyncWebServerRequest *request, JsonVariant &json) {
|
|||
request->send(200, _encoding_json, g_async_content);
|
||||
return;
|
||||
}
|
||||
// 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;
|
||||
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);
|
||||
AsyncWebServerResponse *response = request->beginResponse(429, _encoding_json, g_async_content);
|
||||
response->addHeader("Retry-After", String(secs));
|
||||
request->send(response);
|
||||
return;
|
||||
}
|
||||
char username[33] = "";
|
||||
char password[33] = "";
|
||||
char pin[5] = "";
|
||||
|
|
@ -363,12 +380,15 @@ void Web::handleLogin(AsyncWebServerRequest *request, JsonVariant &json) {
|
|||
if(String(ptok) == String(token)) success = true;
|
||||
}
|
||||
if(success) {
|
||||
loginThrottle.succeeded((uint32_t)ip);
|
||||
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());
|
||||
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}",
|
||||
static_cast<uint8_t>(settings.Security.type));
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue