From a6f49e2b63e058a5df7181b3bffc85b0964d656e Mon Sep 17 00:00:00 2001 From: nullrouten0 <201262769+nullrouten0@users.noreply.github.com> Date: Sun, 18 Oct 2026 02:45:53 +0000 Subject: [PATCH] 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 --- examples/simple_room_server/MyMesh.cpp | 27 ++++- examples/simple_room_server/MyMesh.h | 8 ++ test/fixtures/room_web_service/service.cpp | 126 ++++++++++++++++++++- test/test_room_web_service.py | 4 +- 4 files changed, 162 insertions(+), 3 deletions(-) diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index 2a4441a65..16345d7f7 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -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) { diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index f1c1799ee..415176fff 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -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(); diff --git a/test/fixtures/room_web_service/service.cpp b/test/fixtures/room_web_service/service.cpp index 0129cc8ae..2d52c5f98 100644 --- a/test/fixtures/room_web_service/service.cpp +++ b/test/fixtures/room_web_service/service.cpp @@ -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 static RoomLoginAuthorization countedAuthorizeRoomLogin(Args... args) { + ++password_checks; return authorizeRoomLogin(args...); +} +} +#define authorizeRoomLogin countedAuthorizeRoomLogin #include "production.inc" +#undef authorizeRoomLogin static std::array token(uint8_t id = 1) { std::array 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); } diff --git a/test/test_room_web_service.py b/test/test_room_web_service.py index b02157d41..014ff7683 100644 --- a/test/test_room_web_service.py +++ b/test/test_room_web_service.py @@ -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 + ":")