mirror of
https://github.com/rstrouse/ESPSomfy-RTS.git
synced 2026-10-31 13:52:15 +01:00
PR C of SECURITY_PLAN.md: after 5 failed /login attempts an IP is refused for 30 s, and each failure after that refuses it for twice as long, up to 15 min. A success resets it, as does an hour without a failure. - LoginThrottle.h holds the schedule and 8 IPs, with the time passed in so native tests cover it. While all 8 hold recent failures, every other IP shares one more slot, so many addresses don't each get five free tries. - A refused IP gets 429 with Retry-After before its credentials are checked, so a right guess during a lockout reveals nothing. Home Assistant retries a 429 instead of asking for new credentials. - The web UI's login panel shows a 401's or 429's message, where it popped a service error. - test/e2e --lockout checks it on a Secured device and waits it out. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
149 lines
6.1 KiB
C++
149 lines
6.1 KiB
C++
#include <unity.h>
|
|
#include "LoginThrottle.h"
|
|
|
|
void setUp() {}
|
|
void tearDown() {}
|
|
|
|
static const uint32_t IP_A = 0x0A01A8C0; // 192.168.1.10
|
|
static const uint32_t IP_B = 0x0B01A8C0; // 192.168.1.11
|
|
|
|
// Fails ip n times at now, as fast as the throttle lets it.
|
|
static void failTimes(LoginThrottle &t, uint32_t ip, int n, uint32_t now) {
|
|
for(int i = 0; i < n; i++) t.failed(ip, now);
|
|
}
|
|
|
|
/*********************************************************************
|
|
* The schedule
|
|
********************************************************************/
|
|
static void test_the_first_failures_are_free() {
|
|
for(uint16_t f = 0; f < LOGIN_FREE_FAILURES; f++)
|
|
TEST_ASSERT_EQUAL_UINT32(0, LoginThrottle::lockoutFor(f));
|
|
}
|
|
static void test_the_fifth_failure_locks_out_for_30s() {
|
|
TEST_ASSERT_EQUAL_UINT32(30000, LoginThrottle::lockoutFor(5));
|
|
}
|
|
static void test_each_later_failure_doubles_up_to_15_minutes() {
|
|
TEST_ASSERT_EQUAL_UINT32(60000, LoginThrottle::lockoutFor(6));
|
|
TEST_ASSERT_EQUAL_UINT32(120000, LoginThrottle::lockoutFor(7));
|
|
TEST_ASSERT_EQUAL_UINT32(240000, LoginThrottle::lockoutFor(8));
|
|
TEST_ASSERT_EQUAL_UINT32(480000, LoginThrottle::lockoutFor(9));
|
|
TEST_ASSERT_EQUAL_UINT32(900000, LoginThrottle::lockoutFor(10));
|
|
TEST_ASSERT_EQUAL_UINT32(900000, LoginThrottle::lockoutFor(11));
|
|
TEST_ASSERT_EQUAL_UINT32(900000, LoginThrottle::lockoutFor(UINT16_MAX));
|
|
}
|
|
|
|
/*********************************************************************
|
|
* One IP
|
|
********************************************************************/
|
|
static void test_an_ip_with_no_failures_may_try() {
|
|
LoginThrottle t;
|
|
TEST_ASSERT_EQUAL_UINT32(0, t.retryAfter(IP_A, 1000));
|
|
}
|
|
static void test_four_failures_cost_nothing() {
|
|
LoginThrottle t;
|
|
for(int i = 0; i < 4; i++) TEST_ASSERT_EQUAL_UINT32(0, t.failed(IP_A, 1000));
|
|
TEST_ASSERT_EQUAL_UINT32(0, t.retryAfter(IP_A, 1000));
|
|
}
|
|
static void test_the_fifth_failure_refuses_the_ip_for_30s() {
|
|
LoginThrottle t;
|
|
failTimes(t, IP_A, 4, 1000);
|
|
TEST_ASSERT_EQUAL_UINT32(30000, t.failed(IP_A, 1000));
|
|
TEST_ASSERT_EQUAL_UINT32(30000, t.retryAfter(IP_A, 1000));
|
|
TEST_ASSERT_EQUAL_UINT32(1, t.retryAfter(IP_A, 30999));
|
|
TEST_ASSERT_EQUAL_UINT32(0, t.retryAfter(IP_A, 31000));
|
|
}
|
|
static void test_after_a_lockout_the_next_failure_locks_out_twice_as_long() {
|
|
LoginThrottle t;
|
|
failTimes(t, IP_A, 5, 0);
|
|
TEST_ASSERT_EQUAL_UINT32(60000, t.failed(IP_A, 30000));
|
|
TEST_ASSERT_EQUAL_UINT32(60000, t.retryAfter(IP_A, 30000));
|
|
TEST_ASSERT_EQUAL_UINT32(120000, t.failed(IP_A, 90000));
|
|
}
|
|
static void test_a_success_resets_the_ip() {
|
|
LoginThrottle t;
|
|
failTimes(t, IP_A, 6, 0);
|
|
t.succeeded(IP_A);
|
|
TEST_ASSERT_EQUAL_UINT32(0, t.retryAfter(IP_A, 0));
|
|
// Its free failures are back.
|
|
for(int i = 0; i < 4; i++) TEST_ASSERT_EQUAL_UINT32(0, t.failed(IP_A, 0));
|
|
TEST_ASSERT_EQUAL_UINT32(30000, t.failed(IP_A, 0));
|
|
}
|
|
static void test_an_hour_without_a_failure_forgets_the_ip() {
|
|
LoginThrottle t;
|
|
failTimes(t, IP_A, 7, 0);
|
|
// Still counted just before the hour: the next failure doubles.
|
|
TEST_ASSERT_EQUAL_UINT32(240000, t.failed(IP_A, LOGIN_FORGET_MS - 1));
|
|
uint32_t later = 2 * LOGIN_FORGET_MS;
|
|
TEST_ASSERT_EQUAL_UINT32(0, t.failed(IP_A, later));
|
|
}
|
|
static void test_millis_wrapping_keeps_the_lockout() {
|
|
LoginThrottle t;
|
|
uint32_t now = UINT32_MAX - 10000;
|
|
failTimes(t, IP_A, 5, now);
|
|
TEST_ASSERT_EQUAL_UINT32(10000, t.retryAfter(IP_A, now + 20000));
|
|
TEST_ASSERT_EQUAL_UINT32(0, t.retryAfter(IP_A, now + 30000));
|
|
}
|
|
|
|
/*********************************************************************
|
|
* Several IPs
|
|
********************************************************************/
|
|
static void test_one_ip_locked_out_leaves_the_others_alone() {
|
|
LoginThrottle t;
|
|
failTimes(t, IP_A, 5, 0);
|
|
TEST_ASSERT_EQUAL_UINT32(0, t.retryAfter(IP_B, 0));
|
|
TEST_ASSERT_EQUAL_UINT32(0, t.failed(IP_B, 0));
|
|
}
|
|
static void test_a_success_resets_only_its_own_ip() {
|
|
LoginThrottle t;
|
|
failTimes(t, IP_A, 5, 0);
|
|
t.succeeded(IP_B);
|
|
TEST_ASSERT_EQUAL_UINT32(30000, t.retryAfter(IP_A, 0));
|
|
}
|
|
static void test_with_the_table_full_other_ips_share_one_slot() {
|
|
LoginThrottle t;
|
|
for(uint32_t i = 0; i < LOGIN_THROTTLE_SLOTS; i++) failTimes(t, 100 + i, 1, 0);
|
|
// New addresses count against each other: five between them lock all out.
|
|
for(uint32_t i = 0; i < 4; i++) TEST_ASSERT_EQUAL_UINT32(0, t.failed(200 + i, 0));
|
|
TEST_ASSERT_EQUAL_UINT32(30000, t.failed(204, 0));
|
|
TEST_ASSERT_EQUAL_UINT32(30000, t.retryAfter(205, 0));
|
|
// The IPs that have a slot keep their own count.
|
|
TEST_ASSERT_EQUAL_UINT32(0, t.retryAfter(100, 0));
|
|
}
|
|
static void test_a_forgotten_slot_is_taken_again() {
|
|
LoginThrottle t;
|
|
for(uint32_t i = 0; i < LOGIN_THROTTLE_SLOTS; i++) failTimes(t, 100 + i, 5, 0);
|
|
// An hour later every slot is vacant: a new IP has its own free failures.
|
|
uint32_t later = LOGIN_FORGET_MS;
|
|
TEST_ASSERT_EQUAL_UINT32(0, t.retryAfter(IP_A, later));
|
|
failTimes(t, IP_A, 4, later);
|
|
TEST_ASSERT_EQUAL_UINT32(30000, t.failed(IP_A, later));
|
|
// Taken by IP_A, not shared: another new IP isn't refused.
|
|
TEST_ASSERT_EQUAL_UINT32(0, t.retryAfter(IP_B, later));
|
|
}
|
|
static void test_a_success_doesnt_clear_the_shared_slot() {
|
|
LoginThrottle t;
|
|
for(uint32_t i = 0; i < LOGIN_THROTTLE_SLOTS; i++) failTimes(t, 100 + i, 1, 0);
|
|
failTimes(t, 200, 5, 0);
|
|
t.succeeded(201);
|
|
TEST_ASSERT_EQUAL_UINT32(30000, t.retryAfter(202, 0));
|
|
}
|
|
|
|
int main(int argc, char **argv) {
|
|
UNITY_BEGIN();
|
|
RUN_TEST(test_the_first_failures_are_free);
|
|
RUN_TEST(test_the_fifth_failure_locks_out_for_30s);
|
|
RUN_TEST(test_each_later_failure_doubles_up_to_15_minutes);
|
|
RUN_TEST(test_an_ip_with_no_failures_may_try);
|
|
RUN_TEST(test_four_failures_cost_nothing);
|
|
RUN_TEST(test_the_fifth_failure_refuses_the_ip_for_30s);
|
|
RUN_TEST(test_after_a_lockout_the_next_failure_locks_out_twice_as_long);
|
|
RUN_TEST(test_a_success_resets_the_ip);
|
|
RUN_TEST(test_an_hour_without_a_failure_forgets_the_ip);
|
|
RUN_TEST(test_millis_wrapping_keeps_the_lockout);
|
|
RUN_TEST(test_one_ip_locked_out_leaves_the_others_alone);
|
|
RUN_TEST(test_a_success_resets_only_its_own_ip);
|
|
RUN_TEST(test_with_the_table_full_other_ips_share_one_slot);
|
|
RUN_TEST(test_a_forgotten_slot_is_taken_again);
|
|
RUN_TEST(test_a_success_doesnt_clear_the_shared_slot);
|
|
return UNITY_END();
|
|
}
|