fix(rate-limit): secchio login esteso a credenziali supervisore e setup (#8)

Il secchio "login" del rate limit valeva solo per /api/auth/login. Un
capoturno può ora essere attaccato a forza bruta anche via
/api/measurements/{id}/authorise e /api/production-runs/{id}/pause|
resume|close, senza mai toccare il bucket più permissivo "general".
_is_login_bucket_request confronta il path con un'espressione regolare,
non con ==, perché l'id in mezzo varia per richiesta.

Include anche la parte di #9 che riguarda questo stesso file: i path
sotto /api/setup/ entrano nello stesso secchio, indipendentemente dal
metodo (GET /api/setup/status accetta ora una password opzionale).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-21 22:34:42 +02:00
parent 1921bc3302
commit e91ef739af
3 changed files with 100 additions and 4 deletions
@@ -121,6 +121,44 @@ def test_trusted_proxy_count_zero_ignores_all_proxy_headers(monkeypatch):
assert RateLimitMiddleware._client_ip(req) == "172.18.0.9"
class TestLoginBucketPaths:
"""_is_login_bucket_request: which paths share the login rate-limit bucket."""
def test_login_path_is_login_bucket(self):
assert RateLimitMiddleware._is_login_bucket_request("POST", "/api/auth/login")
def test_login_path_get_is_not_login_bucket(self):
assert not RateLimitMiddleware._is_login_bucket_request("GET", "/api/auth/login")
def test_measurement_authorise_is_login_bucket(self):
assert RateLimitMiddleware._is_login_bucket_request(
"POST", "/api/measurements/42/authorise"
)
def test_production_pause_resume_close_are_login_bucket(self):
for action in ("pause", "resume", "close"):
assert RateLimitMiddleware._is_login_bucket_request(
"POST", f"/api/production-runs/7/{action}"
)
def test_production_cycle_is_not_login_bucket(self):
"""Only the supervisor-gated actions, not every production-runs path."""
assert not RateLimitMiddleware._is_login_bucket_request(
"POST", "/api/production-runs/7/cycle"
)
def test_setup_paths_are_login_bucket_any_method(self):
assert RateLimitMiddleware._is_login_bucket_request("POST", "/api/setup/init-db")
assert RateLimitMiddleware._is_login_bucket_request("GET", "/api/setup/status")
def test_bare_setup_page_is_not_login_bucket(self):
"""The HTML setup page itself carries no password."""
assert not RateLimitMiddleware._is_login_bucket_request("GET", "/api/setup")
def test_unrelated_path_is_not_login_bucket(self):
assert not RateLimitMiddleware._is_login_bucket_request("POST", "/api/recipes")
class TestEffectiveLimit:
"""_effective_limit: dividing a configured limit across worker processes."""
+26
View File
@@ -124,6 +124,32 @@ class TestRateLimiting:
)
assert resp.status_code == 429
async def test_production_run_pause_shares_login_bucket(self, client: AsyncClient):
"""Repeated wrong-password POSTs to a supervisor-gated action are rate
limited under the login bucket, not the (much higher) general one."""
for i in range(5):
resp = await client.post(
f"/api/production-runs/{i + 1}/pause",
json={
"supervisor_username": "capoturno",
"supervisor_password": "wrong",
"note": None,
},
)
# Unauthenticated (no X-API-Key) or run not found either way -
# what matters here is that it is not yet rate limited.
assert resp.status_code != 429
resp = await client.post(
"/api/production-runs/999/pause",
json={
"supervisor_username": "capoturno",
"supervisor_password": "wrong",
"note": None,
},
)
assert resp.status_code == 429
class TestPathTraversal:
"""Path traversal protection in file endpoints."""