/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>
121 lines
4.7 KiB
C++
121 lines
4.7 KiB
C++
#include <cassert>
|
|
#include <cstdio>
|
|
#include <helpers/WebConfigLoginThrottle.h>
|
|
|
|
using namespace WebConfigLoginThrottle;
|
|
|
|
static const uint32_t A = 0x0204A8C0, B = 0x0304A8C0;
|
|
|
|
static void fail(Tracker& t, uint32_t client, uint32_t now, int times = 1) {
|
|
for (int i = 0; i < times; i++) {
|
|
assert(t.check(client, now) == ALLOW);
|
|
t.noteFailure(client, now);
|
|
}
|
|
}
|
|
|
|
int main() {
|
|
// One address at its limit is locked on its own; others still get a try.
|
|
{
|
|
Tracker t; uint32_t now = 100000;
|
|
fail(t, A, now, kClientFails - 1);
|
|
assert(t.check(A, now) == ALLOW);
|
|
t.noteFailure(A, now);
|
|
assert(t.check(A, now) == CLIENT_LOCKED);
|
|
assert(t.check(B, now) == ALLOW);
|
|
t.noteSuccess(B);
|
|
assert(t.check(A, now) == CLIENT_LOCKED); // another client's success is not A's
|
|
assert(t.check(A, now + kClientLockMs - 1) == CLIENT_LOCKED);
|
|
assert(t.check(A, now + kClientLockMs) == ALLOW);
|
|
fail(t, A, now + kClientLockMs, kClientFails - 1); // the count restarted
|
|
assert(t.check(A, now + kClientLockMs) == ALLOW);
|
|
}
|
|
// A success clears only that client's own count.
|
|
{
|
|
Tracker t; uint32_t now = 100000;
|
|
fail(t, A, now, kClientFails - 1);
|
|
t.noteSuccess(A);
|
|
now += kClientLockMs; // let the shared budget refill
|
|
fail(t, A, now, kClientFails - 1);
|
|
assert(t.check(A, now) == ALLOW);
|
|
}
|
|
// A client failing as fast as it is allowed never empties the shared budget,
|
|
// so another address is never refused, while its own rate stays at the cap.
|
|
{
|
|
Tracker t; unsigned failures = 0;
|
|
for (uint32_t now = 100000; now < 100000 + 3600000; now += 100) {
|
|
if (t.check(A, now) == ALLOW) { t.noteFailure(A, now); failures++; }
|
|
assert(t.check(B, now) == ALLOW);
|
|
}
|
|
printf("one address, 1 h: %u failures\n", failures);
|
|
assert(failures <= kClientFails * (3600000 / kClientLockMs + 1));
|
|
}
|
|
// Changing address on every attempt is bounded by the shared budget: a
|
|
// burst of kSharedBudget, then one failure per kSharedRefillMs.
|
|
{
|
|
Tracker t; uint32_t ip = 1; unsigned failures = 0;
|
|
const uint32_t start = 100000;
|
|
for (int i = 0; i < kSharedBudget; i++) fail(t, ip++, start);
|
|
assert(t.check(ip, start) == SHARED_LOCKED);
|
|
assert(t.check(ip, start + kSharedRefillMs - 1) == SHARED_LOCKED);
|
|
fail(t, ip++, start + kSharedRefillMs);
|
|
assert(t.check(ip, start + kSharedRefillMs) == SHARED_LOCKED);
|
|
for (uint32_t now = start + kSharedRefillMs; now < start + 3600000; now += 10) {
|
|
if (t.check(ip, now) == ALLOW) { t.noteFailure(ip++, now); failures++; }
|
|
}
|
|
failures += kSharedBudget + 1;
|
|
printf("new address per attempt, 1 h: %u failures\n", failures);
|
|
assert(failures <= kSharedBudget + 3600000 / kSharedRefillMs);
|
|
}
|
|
// The more specific answer wins when both limits apply.
|
|
{
|
|
Tracker t; uint32_t now = 100000;
|
|
fail(t, A, now, kClientFails);
|
|
fail(t, B, now, kSharedBudget - kClientFails);
|
|
assert(t.check(A, now) == CLIENT_LOCKED);
|
|
assert(t.check(B, now) == SHARED_LOCKED);
|
|
}
|
|
// Locks are measured as elapsed time, so an old lock does not come back
|
|
// when millis() is half a wrap past it.
|
|
{
|
|
Tracker t; uint32_t now = 100000;
|
|
fail(t, A, now, kClientFails);
|
|
fail(t, B, now, kSharedBudget - kClientFails);
|
|
now += 0x80000000u + 60000u;
|
|
assert(t.check(A, now) == ALLOW);
|
|
assert(t.check(B, now) == ALLOW);
|
|
}
|
|
// Failures recorded without a check saturate instead of wrapping.
|
|
{
|
|
Tracker t; uint32_t now = 100000;
|
|
for (int i = 0; i < 256; i++) t.noteFailure(A, now);
|
|
assert(t.check(A, now) == CLIENT_LOCKED);
|
|
assert(t.check(B, now + kSharedRefillMs - 1) == SHARED_LOCKED);
|
|
assert(t.check(B, now + kSharedRefillMs) == ALLOW);
|
|
}
|
|
// A failure recorded later without a check first still counts in full.
|
|
{
|
|
Tracker t; uint32_t now = 100000;
|
|
t.noteFailure(1, now);
|
|
t.noteFailure(2, now += 60000);
|
|
for (uint32_t ip = 3; ip < 3 + kSharedBudget - 1; ip++) fail(t, ip, now);
|
|
assert(t.check(A, now) == SHARED_LOCKED);
|
|
}
|
|
// A free slot is reused before an in-use one; otherwise the slot with the
|
|
// oldest failure is evicted.
|
|
{
|
|
Tracker t; uint32_t now = 100000;
|
|
fail(t, 1, now);
|
|
fail(t, 2, now += kSharedRefillMs);
|
|
fail(t, 3, now += kSharedRefillMs);
|
|
fail(t, 4, now += kSharedRefillMs);
|
|
t.noteSuccess(4);
|
|
fail(t, 5, now += kSharedRefillMs); // takes 4's freed slot; 1 keeps its count
|
|
for (int i = 0; i < kClientFails - 1; i++) fail(t, 1, now += kSharedRefillMs);
|
|
assert(t.check(1, now) == CLIENT_LOCKED);
|
|
fail(t, 6, now += kSharedRefillMs); // all in use: evicts 2, the oldest
|
|
for (int i = 0; i < kClientFails - 1; i++) fail(t, 2, now += kSharedRefillMs);
|
|
assert(t.check(2, now) == ALLOW);
|
|
}
|
|
puts("WebConfig login throttle policy checks passed");
|
|
}
|