Lock WebConfig logins per client address with a shared failure cap

/api/login locked every client for 30 s after five failures from anyone.
Anyone who could reach the login page could keep the owner locked out by
failing on purpose. If no login succeeded after a lockout, the lock also
returned from 2^31 to 2^32 ms (about 24.9 to 49.7 days) later, because the
deadline was never cleared and was compared as a signed difference.

Count failures per remote address instead. Five failures lock that address
for 30 s, as before. A shared budget caps failures from all addresses
together, so changing address does not add guesses: it holds six failures
and refills one every 6 s, the old global rate, so one failing address can
never empty it on its own. While it is empty every client waits, as before.
Lock times are measured as elapsed time.

The policy is a pure header, WebConfigLoginThrottle.h.
test_webconfig_login_throttle.py runs it directly (isolation, boundaries,
one-hour rate bounds, millis() half-wrap, saturation, eviction) and runs the
real handleLogin with ArduinoJson under ASan/UBSan. Putting every client on
one key, as the old code did, fails the handler test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
nullrouten0andClaude Opus 5.5 committed 2026-10-18 03:57:09 +00:00
1 parent 0e72474f41
commit ef1e4a70cb
10 files changed
+423 -14

No files matched your search

+108
View File
@@ -0,0 +1,108 @@
#pragma once
#include <stdint.h>
// Pure, platform-independent failed-login accounting for WebConfig's
// /api/login. Each client address is locked on its own after kClientFails
// failures, so one client that keeps failing cannot lock the owner out from
// another address.
//
// Addresses are cheap to change, so a shared budget also caps the failures of
// all clients together. It refills at the rate one client may fail
// (kClientFails per kClientLockMs, the old global rate) and holds one failure
// more than a single client can spend, so one failing client alone never
// empties it. While it is empty every client waits, as under the old global
// lockout.
namespace WebConfigLoginThrottle {
static const uint8_t kClientFails = 5;
static const uint32_t kClientLockMs = 30000;
static const uint8_t kSharedBudget = kClientFails + 1;
static const uint32_t kSharedRefillMs = kClientLockMs / kClientFails;
// Tracked failing clients. Evicting one only resets that client's count; the
// shared budget still bounds the total.
static const uint8_t kClients = 4;
enum Verdict { ALLOW, CLIENT_LOCKED, SHARED_LOCKED };
class Tracker {
public:
Tracker() : _spent(0), _refill_at(0) {}
Verdict check(uint32_t client, uint32_t now) {
refill(now);
// A client at kClientFails is locked until kClientLockMs after its last
// failure; expire every slot here so a stale lock is not kept around.
for (uint8_t i = 0; i < kClients; i++) {
if (_slots[i].fails >= kClientFails && now - _slots[i].last >= kClientLockMs) {
_slots[i].fails = 0;
}
}
const Slot* slot = find(client);
if (slot && slot->fails >= kClientFails) return CLIENT_LOCKED;
if (_spent >= kSharedBudget) return SHARED_LOCKED;
return ALLOW;
}
void noteFailure(uint32_t client, uint32_t now) {
refill(now);
if (_spent == 0) _refill_at = now;
if (_spent < kSharedBudget) _spent++;
Slot* slot = find(client);
if (!slot) slot = claim(client, now);
slot->last = now;
if (slot->fails < kClientFails) slot->fails++;
}
// Clears only this client's count. The shared budget keeps draining on its
// own, so a success does not hand other clients a fresh burst.
void noteSuccess(uint32_t client) {
Slot* slot = find(client);
if (slot) slot->fails = 0;
}
private:
struct Slot {
uint32_t client = 0;
uint32_t last = 0;
uint8_t fails = 0; // 0 = free slot
};
Slot _slots[kClients];
uint8_t _spent;
uint32_t _refill_at;
void refill(uint32_t now) {
const uint32_t steps = (now - _refill_at) / kSharedRefillMs;
if (steps >= _spent) {
_spent = 0;
return;
}
_spent -= (uint8_t)steps;
_refill_at += steps * kSharedRefillMs;
}
Slot* find(uint32_t client) {
for (uint8_t i = 0; i < kClients; i++) {
if (_slots[i].fails > 0 && _slots[i].client == client) return &_slots[i];
}
return nullptr;
}
// A free slot, else the one whose last failure is oldest.
Slot* claim(uint32_t client, uint32_t now) {
Slot* victim = &_slots[0];
for (uint8_t i = 0; i < kClients; i++) {
if (_slots[i].fails == 0) {
victim = &_slots[i];
break;
}
if (now - _slots[i].last > now - victim->last) victim = &_slots[i];
}
victim->client = client;
victim->fails = 0;
return victim;
}
};
} // namespace WebConfigLoginThrottle
+11 -8
View File
@@ -2067,10 +2067,18 @@ void WebConfigServer::handleLogin(AsyncWebServerRequest* req) {
return;
}
uint32_t now = millis();
if (_login_lock_until && (int32_t)(now - _login_lock_until) < 0) {
// Failures lock the failing address, not every client; a shared budget in
// WebConfigLoginThrottle still caps guesses across addresses.
const uint32_t client = req->client() ? req->client()->getRemoteAddress() : 0;
const WebConfigLoginThrottle::Verdict verdict = _login_throttle.check(client, now);
if (verdict == WebConfigLoginThrottle::CLIENT_LOCKED) {
req->send(429, "application/json", "{\"error\":\"locked, retry in 30s\"}");
return;
}
if (verdict == WebConfigLoginThrottle::SHARED_LOCKED) {
req->send(429, "application/json", "{\"error\":\"too many failed logins, retry shortly\"}");
return;
}
const char* body = (const char*)req->_tempObject;
DynamicJsonDocument doc(256);
if (!body || deserializeJson(doc, body) != DeserializationError::Ok) {
@@ -2079,16 +2087,11 @@ void WebConfigServer::handleLogin(AsyncWebServerRequest* req) {
}
const char* pwd = doc["password"] | "";
if (!fixedTimeEquals(pwd, node.admin_password, sizeof(node.admin_password))) {
if (++_login_fails >= 5) {
_login_lock_until = now + 30000;
if (_login_lock_until == 0) _login_lock_until = 1;
_login_fails = 0;
}
_login_throttle.noteFailure(client, now);
req->send(401, "application/json", "{\"error\":\"wrong password\"}");
return;
}
_login_fails = 0;
_login_lock_until = 0;
_login_throttle.noteSuccess(client);
for (int i = 0; i < 4; i++) sprintf(&_session_token[i * 8], "%08lx", (unsigned long)esp_random());
_session_last_seen = now;
noteNetworkActivity();
+2 -2
View File
@@ -36,6 +36,7 @@
#include <helpers/RoomWebMailbox.h>
#include <helpers/WiFiPowerSave.h>
#include <helpers/WiFiReconnectPolicy.h>
#include <helpers/WebConfigLoginThrottle.h>
#include <helpers/WiFiNetworkPowerPolicy.h>
#include <helpers/ui/DisplayBuildFlags.h>
@@ -360,8 +361,7 @@ private:
// LAN-mode session (single slot; new login evicts the old session)
char _session_token[33] = {0};
uint32_t _session_last_seen = 0;
uint8_t _login_fails = 0;
uint32_t _login_lock_until = 0;
WebConfigLoginThrottle::Tracker _login_throttle;
// Once set (via /api/portal/exit), OS captive probes get native "success"
// replies so the phone's sign-in sheet can be dismissed without dropping the