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:
@@ -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."""
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user