mirror of
https://github.com/rstrouse/ESPSomfy-RTS.git
synced 2026-10-31 13:52:15 +01:00
feat: throttle failed logins per client IP
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>
This commit is contained in:
parent
1612fd1587
commit
bab83313ee
8 changed files with 326 additions and 1 deletions
|
|
@ -13,6 +13,7 @@ Runs against a real device on your network, by hand, never in CI. It checks sock
|
|||
| `--radio --shade <id>` | also send real commands: that shade moves a little and back |
|
||||
| `--edit` | also add a room and delete it again |
|
||||
| `--reboot` | also reboot the device and wait for it to come back |
|
||||
| `--lockout` | on a Secured device, also fail `/login` until this PC is refused, then wait out the 30 s lockout |
|
||||
| `--record` | save hello, the sync and the schema to `test/fixtures/proto2/`, for the web UI and Home Assistant tests |
|
||||
|
||||
A plain run sends nothing on air, changes no setting (it saves only values the device already has) and doesn't reboot the device. The command tests send ops that are refused before they reach the radio, and one `setPositions` that sets a shade to the position the sync gave: that drops the fraction of a percent the shade's believed position may have had. The edit tests send edits that are refused, and one shade sort order set to the order it has, which saves the shades again unchanged. The settings tests send saves that are refused, and one that saves the NTP server and time zone the device already has. The frequency scan test pauses the radio's sending for a second or two, as a scan does. It does fill every socket slot for a few seconds, and replays a page load.
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ def pytest_addoption(parser):
|
|||
parser.addoption("--shade", type=int, default=None, help="with --radio: the id of the shade the commands may move")
|
||||
parser.addoption("--edit", action="store_true", help="also run the edits that change the shade model, then undo them")
|
||||
parser.addoption("--reboot", action="store_true", help="also run the test that reboots the device")
|
||||
parser.addoption("--lockout", action="store_true", help="also run the test that locks this PC out of /login for 30 s")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
|
|
|
|||
|
|
@ -3,6 +3,11 @@
|
|||
The suite never changes the device's security itself, so it tests the mode it
|
||||
finds: pass --password or --pin for a Secured device.
|
||||
"""
|
||||
import json
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
import pytest
|
||||
|
||||
from proto_client import ProtoClient
|
||||
|
|
@ -36,3 +41,43 @@ def test_secured_accepts_the_token(host, secured, token):
|
|||
c = ProtoClient(host, params={"apikey": token})
|
||||
assert c.status == 101
|
||||
c.close()
|
||||
|
||||
|
||||
def login_status(host, body):
|
||||
"""/login's status and reply, refusals included."""
|
||||
req = urllib.request.Request(
|
||||
f"http://{host}/login",
|
||||
data=json.dumps(body).encode(),
|
||||
method="PUT",
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=20) as r:
|
||||
return r.status, json.loads(r.read()), r.headers
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code, json.loads(e.read() or b"{}"), e.headers
|
||||
|
||||
|
||||
def test_failed_logins_lock_this_pc_out(request, host, secured, token):
|
||||
"""Five wrong logins refuse this IP for 30 s, even with the right ones (SECURITY_PLAN.md, PR C)."""
|
||||
if not request.config.getoption("--lockout"):
|
||||
pytest.skip("locks this PC out of /login for 30 s: pass --lockout")
|
||||
if not secured:
|
||||
pytest.skip("the device's security is None")
|
||||
password = request.config.getoption("--password")
|
||||
pin = request.config.getoption("--pin")
|
||||
right = {"username": request.config.getoption("--username"), "password": password or "", "pin": pin or ""}
|
||||
wrong = {**right, "password": (password or "") + "x", "pin": "0000" if pin != "0000" else "1111"}
|
||||
# The token fixture's login started this PC with a clean count.
|
||||
for _ in range(5):
|
||||
status, reply, _ = login_status(host, wrong)
|
||||
assert status == 401 and reply["success"] is False
|
||||
status, reply, headers = login_status(host, right)
|
||||
assert status == 429, "the right credentials got through a lockout"
|
||||
wait = int(headers["Retry-After"])
|
||||
assert 25 <= wait <= 30
|
||||
assert reply["retryAfter"] == wait
|
||||
time.sleep(wait + 1)
|
||||
# Over: the right credentials work, and reset the count.
|
||||
status, reply, _ = login_status(host, right)
|
||||
assert status == 200 and reply["apiKey"] == token
|
||||
|
|
|
|||
149
test/test_loginthrottle/test_main.cpp
Normal file
149
test/test_loginthrottle/test_main.cpp
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
#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();
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue