DeterministicESPAsyncWebServer v6.27.1
Zero-allocation, bounded-execution async HTTP server for ESP32
Loading...
Searching...
No Matches
auth_lockout.cpp
Go to the documentation of this file.
1// Copyright (C) 2026 Douglas Quigg (dstroy0) <dquigg123@gmail.com>
2// SPDX-License-Identifier: AGPL-3.0-or-later
3
4/**
5 * @file auth_lockout.cpp
6 * @brief Per-peer brute-force lockout state machine (DETWS_ENABLE_AUTH_LOCKOUT).
7 *
8 * A bounded BSS table of buckets keyed by the source address (a DetIp, so IPv4 and IPv6 peers are
9 * each their own bucket - never a lossy hash that a colliding address could poison). Each bucket
10 * holds the consecutive-failure count and, once the threshold is crossed, the start and duration
11 * of the current lockout (exponential backoff, capped). Compiled only when DETWS_ENABLE_AUTH_LOCKOUT
12 * is set; the host unit tests enable it.
13 */
14
15#include "auth_lockout.h"
16
17#if DETWS_ENABLE_AUTH_LOCKOUT
18
19namespace
20{
21
22struct LockoutBucket
23{
24 DetIp addr; ///< source address (family DetIpFamily::DET_IP_NONE marks an empty bucket).
25 uint32_t lock_start_ms; ///< millis() when the current lockout began.
26 uint32_t lock_ms; ///< current lockout duration (0 = not locked).
27 uint32_t last_ms; ///< millis() of the last recorded failure (LRU eviction).
28 uint16_t fails; ///< consecutive failures from this address.
29};
30
31// All lockout state, owned by one instance (internal linkage): the per-peer bucket table,
32// so it is one named owner, unreachable from any other translation unit.
33struct LockoutCtx
34{
35 LockoutBucket buckets[DETWS_AUTH_LOCKOUT_SLOTS];
36};
37LockoutCtx s_lock;
38
39// Returns a mutable bucket (callers mutate it), so it takes the owner by non-const reference.
40LockoutBucket *find_bucket(LockoutCtx &c, const DetIp *ip)
41{
42 for (int i = 0; i < DETWS_AUTH_LOCKOUT_SLOTS; i++)
43 if (c.buckets[i].addr.family != DetIpFamily::DET_IP_NONE && det_ip_equal(&c.buckets[i].addr, ip))
44 return &c.buckets[i];
45 return nullptr;
46}
47
48bool bucket_locked(const LockoutBucket *b, uint32_t now_ms)
49{
50 // Unsigned subtraction wraps correctly across the millis() rollover.
51 return b->lock_ms != 0 && (uint32_t)(now_ms - b->lock_start_ms) < b->lock_ms;
52}
53
54} // namespace
55
56uint32_t auth_lockout_remaining_ms(const DetIp *ip, uint32_t now_ms)
57{
59 return 0; // untrackable source -> never reported as locked
60 LockoutBucket *b = find_bucket(s_lock, ip);
61 if (!b || b->lock_ms == 0)
62 return 0;
63 uint32_t elapsed = now_ms - b->lock_start_ms; // wraps correctly across rollover
64 if (elapsed >= b->lock_ms)
65 return 0; // the lockout window has passed
66 return b->lock_ms - elapsed;
67}
68
69void auth_lockout_fail(const DetIp *ip, uint32_t now_ms)
70{
72 return; // untrackable source
73
74 LockoutBucket *b = find_bucket(s_lock, ip);
75 if (!b)
76 {
77 // Claim a bucket: an empty one first; else evict the least-recently-used
78 // address that is NOT currently locked; only if every bucket is locked do
79 // we evict the overall LRU (so an active attacker cannot evict their own
80 // lockout by flooding from other addresses).
81 int slot = -1;
82 int lru = 0;
83 for (int i = 0; i < DETWS_AUTH_LOCKOUT_SLOTS; i++)
84 {
85 if (s_lock.buckets[i].addr.family == DetIpFamily::DET_IP_NONE)
86 {
87 slot = i;
88 break;
89 }
90 if ((uint32_t)(now_ms - s_lock.buckets[i].last_ms) > (uint32_t)(now_ms - s_lock.buckets[lru].last_ms))
91 lru = i;
92 if (!bucket_locked(&s_lock.buckets[i], now_ms) &&
93 (slot < 0 ||
94 (uint32_t)(now_ms - s_lock.buckets[i].last_ms) > (uint32_t)(now_ms - s_lock.buckets[slot].last_ms)))
95 slot = i;
96 }
97 if (slot < 0)
98 slot = lru; // table full of active lockouts
99 b = &s_lock.buckets[slot];
100 b->addr = *ip;
101 b->fails = 0;
102 b->lock_ms = 0;
103 b->lock_start_ms = now_ms;
104 }
105
106 b->last_ms = now_ms;
107 if (b->fails < 0xFFFF)
108 b->fails++;
109
110 if (b->fails >= DETWS_AUTH_LOCKOUT_THRESHOLD)
111 {
112 // Exponential backoff: base << (fails - threshold), capped at max. Step the
113 // double so the shift can never overflow: the cap is hit (and the loop
114 // breaks) before dur could exceed MAX_MS, and the config caps MAX_MS at
115 // 0x80000000 so the surviving dur << 1 always fits in a uint32.
116 uint32_t dur = DETWS_AUTH_LOCKOUT_BASE_MS;
117 for (uint16_t n = (uint16_t)(b->fails - DETWS_AUTH_LOCKOUT_THRESHOLD); n > 0; n--)
118 {
119 if (dur >= DETWS_AUTH_LOCKOUT_MAX_MS)
120 {
122 break;
123 }
124 dur <<= 1;
125 }
128 b->lock_ms = dur;
129 b->lock_start_ms = now_ms;
130 }
131}
132
133void auth_lockout_succeed(const DetIp *ip)
134{
135 if (det_ip_is_unspecified(ip))
136 return;
137 LockoutBucket *b = find_bucket(s_lock, ip);
138 if (b)
139 {
140 b->addr.family = DetIpFamily::DET_IP_NONE;
141 b->fails = 0;
142 b->lock_ms = 0;
143 b->lock_start_ms = 0;
144 b->last_ms = 0;
145 }
146}
147
148void auth_lockout_reset(void)
149{
150 for (int i = 0; i < DETWS_AUTH_LOCKOUT_SLOTS; i++)
151 {
152 s_lock.buckets[i].addr.family = DetIpFamily::DET_IP_NONE;
153 s_lock.buckets[i].lock_start_ms = 0;
154 s_lock.buckets[i].lock_ms = 0;
155 s_lock.buckets[i].last_ms = 0;
156 s_lock.buckets[i].fails = 0;
157 }
158}
159
160#endif // DETWS_ENABLE_AUTH_LOCKOUT
#define DETWS_AUTH_LOCKOUT_BASE_MS
First lockout duration in ms; doubles on each further failure.
#define DETWS_AUTH_LOCKOUT_THRESHOLD
Consecutive failed auths from one IP before it is locked out.
#define DETWS_AUTH_LOCKOUT_SLOTS
Number of source IPs the auth lockout tracks (BSS bucket table).
#define DETWS_AUTH_LOCKOUT_MAX_MS
Maximum lockout duration in ms (the exponential backoff cap).
Per-peer brute-force lockout for HTTP auth (DETWS_ENABLE_AUTH_LOCKOUT).
bool det_ip_is_unspecified(const DetIp *ip)
True if ip is empty (DetIpFamily::DET_IP_NONE) or the all-zero unspecified address (0....
Definition ip.cpp:505
bool det_ip_equal(const DetIp *a, const DetIp *b)
True if a and b are the same family and address.
Definition ip.cpp:459
@ DET_IP_NONE
empty / unparsed
A v4 or v6 address in network (big-endian) byte order.
Definition ip.h:52