Limit wrong passwords on the room browser

POST /api/room checked the room password on every request with no failure
limit, and a browser may send a new identity token each time. Any host that
reached WebConfig could guess the room admin password, which is also the
radio and /api/login admin password, at main-loop speed.

One budget now covers all browser tokens, with /api/login's numbers: at most
five distinct wrong passwords in each thirty-second window. After that, a
request carrying a password is refused without checking it until the window
ends. Wrong passwords count in read-only rooms too. Empty passwords are never
counted or refused, so public read-only access keeps working. The last four
wrong passwords are remembered by 8-byte SHA-256 digest and not counted
again, so a tab still polling with an old or mistyped password cannot use up
the budget. Successful sign-ins do not refund failures. Radio login is
unchanged.

test_room_web_service.py gains PASSWORD_LIMIT, which runs the real
processRoomRequest: refusal after five failures from five tokens; identical
replies for wrong, guest and admin passwords while refused, with no password
check (calls to authorizeRoomLogin are counted); no lock extension from 300
refused requests; the 29999/30000 ms boundary and the first window after
boot; read-only and empty-password cases; no refund on success; repeated,
rotating and read-only stale passwords; slow distinct failures ageing out;
and a guard that the digest ring stays in bounds. It fails on the previous
processRoomRequest at the sixth guess.

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 a6f49e2b63
4 files changed
+162 -3

No files matched your search

+26 -1
View File
@@ -438,9 +438,35 @@ void MyMesh::processRoomRequest(const uint8_t* token, uint32_t sequence, char* r
mesh::Identity author;
mesh::Utils::sha256(author.pub_key, PUB_KEY_SIZE, token, 32, self_id.pub_key, PUB_KEY_SIZE);
if (!room_access.allowsIdentity(author.pub_key)) { error("room access denied"); return; }
// Every request carries the password and a browser may send a new token each
// time, so one budget covers all tokens, with /api/login's numbers: five
// wrong passwords per thirty seconds. An empty password tests nothing and
// keeps public read-only access.
auto& limit = room_web_password_limit;
const uint32_t now = millis();
const bool window_open = uint32_t(now - limit.window) < 30000UL;
if (password[0] && limit.failures >= 5 && window_open) {
error("too many wrong room passwords; wait 30 seconds"); return;
}
const auto authorization = mesh::authorizeRoomLogin(password, _prefs.password,
_prefs.guest_password, false, 0, _prefs.allow_read_only, PERM_ACL_ROLE_MASK,
PERM_ACL_GUEST, PERM_ACL_READ_WRITE, PERM_ACL_ADMIN);
// Read-only rooms admit a wrong password as a guest; it still failed. A
// password that already failed reveals nothing new, so a tab still polling
// with an old or mistyped one is counted once, not on every request.
if (password[0] && authorization.role == PERM_ACL_GUEST) {
uint8_t digest[8];
mesh::Utils::sha256(digest, sizeof(digest), (const uint8_t*)password, strlen(password));
digest[0] |= 1; // never matches an unused, all-zero slot
bool repeated = false;
for (const auto& recent : limit.recent) repeated |= memcmp(recent, digest, sizeof(digest)) == 0;
if (!repeated) {
memcpy(limit.recent[limit.recent_next], digest, sizeof(digest));
limit.recent_next = (limit.recent_next + 1) % 4;
if (!limit.failures || !window_open) { limit.window = now; limit.failures = 0; }
++limit.failures;
}
}
if (!authorization.accepted) { error("incorrect room password"); return; }
room_web_request_authenticated = true;
const uint8_t role = authorization.role & PERM_ACL_ROLE_MASK;
@@ -480,7 +506,6 @@ void MyMesh::processRoomRequest(const uint8_t* token, uint32_t sequence, char* r
error("unknown room operation"); return;
}
const uint32_t now = millis();
RoomWebUser* user = nullptr;
RoomWebUser* available = nullptr;
for (auto& entry : room_web_users) {
+8
View File
@@ -349,6 +349,14 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks,
uint32_t last_seen = 0, last_sequence = 0;
uint16_t posts = 0, polls = 0;
} room_web_users[8] = {};
// Wrong room-browser passwords, shared by every browser token: when the
// current thirty-second window began, how many distinct ones failed in it,
// and digests of the last four. RAM only; a reboot starts afresh.
struct RoomWebPasswordLimit {
uint32_t window = 0;
uint8_t failures = 0, recent_next = 0;
uint8_t recent[4][8] = {};
} room_web_password_limit;
bool handleRoomWebCommand(const char* command, char* reply);
bool _unconfigured_setup_espnow_suspended = false;
void suspendUnconfiguredSetupBridges();
+125 -1
View File
@@ -140,6 +140,8 @@ struct MyMesh {
bool room_web_request_authenticated = false;
uint32_t room_web_boot_id = 71;
#include "web_user.inc"
#include "password_limit.inc"
uint8_t password_limit_guard[16] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16};
ACL acl;
Clock clock;
PostInfo posts[MAX_UNSYNCED_POSTS] = {};
@@ -184,7 +186,16 @@ struct MyMesh {
++retry_cancellations; cancelled_retry_timestamp = timestamp;
}
};
// Count every real password evaluation made by the production callbacks.
static unsigned password_checks = 0;
namespace mesh {
template <typename... Args> static RoomLoginAuthorization countedAuthorizeRoomLogin(Args... args) {
++password_checks; return authorizeRoomLogin(args...);
}
}
#define authorizeRoomLogin countedAuthorizeRoomLogin
#include "production.inc"
#undef authorizeRoomLogin
static std::array<uint8_t, 32> token(uint8_t id = 1) { std::array<uint8_t, 32> value = {}; value[0] = id; return value; }
static std::string execute(MyMesh& m, const std::string& input, uint32_t sequence,
@@ -195,6 +206,7 @@ static std::string execute(MyMesh& m, const std::string& input, uint32_t sequenc
const auto key = token(owner);
m.processRoomRequest(key.data(), sequence, request.data(), reply.data() + 1, capacity);
assert(reply.front() == 0x7f && reply.back() == 0x7f && json_allocations == 0);
for (uint8_t i = 0; i < 16; ++i) assert(m.password_limit_guard[i] == i + 1); // password ring stays in bounds
if (!capacity) return "";
assert(memchr(reply.data() + 1, 0, capacity)); return reply.data() + 1;
}
@@ -269,6 +281,117 @@ static void adminAuthorizationCases() {
assert(floors(empty) == 0 && empty.acl.lookups == 0);
printf("ADMIN_AUTH: %u rejected low-trust operations leave state and identity data unchanged\n", denied + 2);
}
// Browsers choose their own tokens, so every token shares one budget of five
// distinct wrong room passwords per thirty seconds.
static std::string guess(const char* prefix, unsigned i) { return prefix + std::to_string(i); }
static void passwordLimitCases() {
uint32_t sequence = 1;
{
MyMesh m; enable(m); m._prefs.allow_read_only = false;
const uint32_t start = ticks; const unsigned checks = password_checks + 5;
for (uint8_t owner = 20; owner < 25; ++owner) {
ticks = start + (owner - 20) * 1000u;
error(m, request("status", guess("guess-", owner).c_str()), sequence++, "incorrect", owner);
}
assert(password_checks == checks);
// Once the budget is spent no password is evaluated, so a correct guess
// looks exactly like a wrong one and never authenticates.
const auto refused = execute(m, request("status", "guess-25"), sequence++, 25);
assert(strstr(refused.c_str(), "too many"));
for (const char* password : {"admin-secret", "write-secret", "guess-20"}) {
assert(execute(m, request("status", password), sequence++, 26) == refused);
assert(floors(m) == 0 && !m.room_web_request_authenticated);
}
// Refused requests are not counted, so a flood can neither wrap the
// counter nor extend the lock.
for (unsigned flood = 0; flood < 300; ++flood) {
assert(execute(m, request("status", guess("flood-", flood).c_str()), sequence++, uint8_t(100 + flood % 100)) == refused);
}
ticks = start + 29999; error(m, request("status", "admin-secret"), sequence++, "too many", 27);
assert(password_checks == checks);
ticks = start + 30000; assert(result(m, request("status", "admin-secret"), sequence++, 27)["role"] == PERM_ACL_ADMIN);
for (uint8_t owner = 30; owner < 35; ++owner) error(m, request("status", guess("later-", owner).c_str()), sequence++, "incorrect", owner);
error(m, request("status", "write-secret"), sequence++, "too many", 28);
}
{
MyMesh m; enable(m);
// Empty passwords test no credential: public readers neither spend the
// budget nor lose read-only access when it is spent.
for (unsigned i = 0; i < 6; ++i) assert(result(m, request("status", ""), sequence++, 20)["role"] == PERM_ACL_GUEST);
assert(result(m, request("status", "admin-secret"), sequence++, 21)["role"] == PERM_ACL_ADMIN);
// Read-only rooms admit a wrong password as a guest, but it still failed.
for (unsigned i = 0; i < 5; ++i) assert(result(m, request("status", guess("guess-", i).c_str()), sequence++, 22)["role"] == PERM_ACL_GUEST);
error(m, request("status", "admin-secret"), sequence++, "too many", 21);
assert(result(m, request("status", ""), sequence++, 20)["role"] == PERM_ACL_GUEST);
}
{
// The first window also starts at the first failure soon after boot.
const uint32_t later = ticks; ticks = 1000;
MyMesh m; enable(m); m._prefs.allow_read_only = false;
for (uint8_t owner = 20; owner < 25; ++owner) error(m, request("status", guess("guess-", owner).c_str()), sequence++, "incorrect", owner);
ticks = 30999; error(m, request("status", "admin-secret"), sequence++, "too many", 25);
ticks = 31000; assert(result(m, request("status", "admin-secret"), sequence++, 25)["role"] == PERM_ACL_ADMIN);
ticks = later;
}
{
MyMesh m; enable(m); m._prefs.allow_read_only = false;
// Neither the guest nor the admin password refunds failed guesses.
for (unsigned i = 0; i < 4; ++i) error(m, request("status", guess("guess-", i).c_str()), sequence++, "incorrect", 20);
assert(result(m, request("status", "write-secret"), sequence++, 21)["role"] == PERM_ACL_READ_WRITE);
assert(result(m, request("status", "admin-secret"), sequence++, 21)["role"] == PERM_ACL_ADMIN);
error(m, request("status", "guess-4"), sequence++, "incorrect", 20);
error(m, request("status", "admin-secret"), sequence++, "too many", 21);
}
{
MyMesh m; enable(m); m._prefs.allow_read_only = false;
// The fixture digest XOR-folds to eight bytes, so this guess folds to zero;
// unused ring slots must not make it look repeated.
error(m, request("status", "abcdefghabcdefgh"), sequence++, "incorrect", 20);
for (unsigned i = 0; i < 4; ++i) error(m, request("status", guess("guess-", i).c_str()), sequence++, "incorrect", 20);
error(m, request("status", "admin-secret"), sequence++, "too many", 21);
}
{
MyMesh m; enable(m); m._prefs.allow_read_only = false;
// Repeating a password that already failed reveals nothing, whichever
// token sends it, so it counts once.
for (uint8_t owner = 20; owner < 70; ++owner) error(m, request("status", "same-guess"), sequence++, "incorrect", owner);
assert(result(m, request("status", "admin-secret"), sequence++, 70)["role"] == PERM_ACL_ADMIN);
ticks += 30000;
// The last four wrong passwords are remembered, so four forgotten tabs
// with different old passwords, each polling every sixteen seconds, never
// lock out the owner. New passwords still count.
for (unsigned poll = 0; poll < 40; ++poll) {
error(m, request("status", guess("old-password-", poll % 4).c_str()), sequence++, "incorrect", uint8_t(80 + poll % 4));
ticks += 4000;
}
assert(result(m, request("status", "admin-secret"), sequence++, 70)["role"] == PERM_ACL_ADMIN);
for (unsigned i = 0; i < 5; ++i) error(m, request("status", guess("new-", i).c_str()), sequence++, "incorrect", 90);
error(m, request("status", "admin-secret"), sequence++, "too many", 70);
}
{
MyMesh m; enable(m);
// A read-only tab with a mistyped password keeps polling status and posts
// as a guest; its repeated password never blocks anyone else.
assert(result(m, request("post", "write-secret", ",\"boot\":71,\"name\":\"A\",\"text\":\"one\""), sequence++, 21)["ok"] == true);
for (unsigned poll = 0; poll < 20; ++poll) {
assert(result(m, request("status", "typo"), sequence++, 22)["role"] == PERM_ACL_GUEST);
for (unsigned page = 0; page < 3; ++page) result(m, request("posts", "typo", ",\"after\":0"), sequence++, 22);
ticks += 15000;
}
assert(result(m, request("status", "admin-secret"), sequence++, 21)["role"] == PERM_ACL_ADMIN);
}
{
MyMesh m; enable(m); m._prefs.allow_read_only = false;
// Distinct failures age out with the window: one every fifteen seconds
// never reaches the limit.
for (unsigned poll = 0; poll < 40; ++poll) {
error(m, request("status", guess("slow-", poll).c_str()), sequence++, "incorrect", 20);
assert(result(m, request("status", "admin-secret"), sequence++, 21)["role"] == PERM_ACL_ADMIN);
ticks += 15000;
}
}
printf("PASSWORD_LIMIT: five distinct wrong room passwords per thirty seconds across all browser tokens\n");
}
static std::string adminClientRequest(const char* operation, const ClientInfo& client,
const std::string& fields = "", uint32_t boot = 71) {
return request(operation, "admin-secret", ",\"boot\":" + std::to_string(boot)
@@ -910,6 +1033,7 @@ int main() {
assert(m._num_posted == 40 && m.next_post_idx == 8 && m.last_room_timestamp == 1039); ++scenarios;
}
adminAuthorizationCases();
passwordLimitCases();
adminRouteCases();
adminCatchUpCases();
adminListingCases();
@@ -926,5 +1050,5 @@ int main() {
assert(result(boundary, saveRequest(1, 0, "Instructions", "Read only this article"), 2)["ok"] == true);
printf("BOUNDARY_INDEX %s\n", execute(boundary, request("board.index", "write-secret", ",\"revision\":0,\"cursor\":0"), 3).c_str());
printf("BOUNDARY_READ %s\n", execute(boundary, request("board.read", "write-secret", ",\"id\":1,\"version\":1,\"offset\":0"), 4).c_str());
printf("PASS: %u base, 6 admin and 6 mail actual room web service authorization, storage, quota, JSON and boundary groups\n", scenarios);
printf("PASS: %u base, 6 admin, 1 password-limit and 6 mail actual room web service authorization, storage, quota, JSON and boundary groups\n", scenarios);
}
+3 -1
View File
@@ -68,6 +68,8 @@ class RoomWebServiceTest(unittest.TestCase):
(work / "post.inc").write_text(extract_braced(header, "struct PostInfo {") + ";")
web_user = extract_braced(header, "struct RoomWebUser {")
(work / "web_user.inc").write_text(web_user + " room_web_users[8] = {};")
(work / "password_limit.inc").write_text(extract_braced(
header, "struct RoomWebPasswordLimit {") + " room_web_password_limit;")
binary = work / "service"
built = subprocess.run([compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror",
"-Wno-misleading-indentation", *sanitizers, "-DESP32_PLATFORM=1",
@@ -80,7 +82,7 @@ class RoomWebServiceTest(unittest.TestCase):
checked = subprocess.run([str(binary)], capture_output=True, text=True, timeout=60)
self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr)
self.assertIn("actual room web service", checked.stdout)
for group in ("ADMIN_AUTH", "ADMIN_ROUTES", "ADMIN_CATCHUP", "ADMIN_USERS",
for group in ("ADMIN_AUTH", "PASSWORD_LIMIT", "ADMIN_ROUTES", "ADMIN_CATCHUP", "ADMIN_USERS",
"ADMIN_SETTINGS", "ADMIN_INVALID", "MAIL_AUTH", "MAIL_DELIVERY",
"MAIL_ADMIN", "MAIL_PAGING", "MAIL_STORAGE", "MAIL_GLOBAL"):
self.assertRegex(checked.stdout, r"(?m)^" + group + ":")