feat: throttle repeated operator sign-in failures (#270)
This commit is contained in:
1 parent
334fe77e84
commit
87becb08ad
8 files changed
+471
-2
No files matched your search
@@ -11,6 +11,9 @@ def access_control(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "correct horse battery staple")
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", "a-separate-session-signing-secret-with-enough-entropy")
|
||||
monkeypatch.setenv("STACKCHAIN_SESSION_DB", str(tmp_path / "sessions.sqlite3"))
|
||||
monkeypatch.setenv("STACKCHAIN_LOGIN_ATTEMPT_DB", str(tmp_path / "login-attempts.sqlite3"))
|
||||
monkeypatch.setenv("STACKCHAIN_LOGIN_MAX_FAILURES", "3")
|
||||
monkeypatch.setenv("STACKCHAIN_LOGIN_WINDOW_SECONDS", "60")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -111,6 +114,43 @@ async def test_sign_in_creates_secure_session_without_echoing_access_token(acces
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_sign_in_throttles_repeated_failures_with_retry_guidance(access_control):
|
||||
transport = httpx.ASGITransport(app=main.app, client=("203.0.113.7", 1234))
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
failures = [
|
||||
await client.post("/api/v1/session", json={"access_token": "wrong"})
|
||||
for _ in range(3)
|
||||
]
|
||||
blocked = await client.post(
|
||||
"/api/v1/session", json={"access_token": "correct horse battery staple"}
|
||||
)
|
||||
|
||||
assert [response.status_code for response in failures] == [401, 401, 401]
|
||||
assert blocked.status_code == 429
|
||||
assert blocked.json() == {"detail": "Too many sign-in attempts"}
|
||||
assert blocked.headers["retry-after"].isdigit()
|
||||
assert blocked.headers["cache-control"] == "no-store"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_successful_sign_in_clears_prior_failures(access_control):
|
||||
transport = httpx.ASGITransport(app=main.app, client=("203.0.113.8", 1234))
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
for _ in range(2):
|
||||
await client.post("/api/v1/session", json={"access_token": "wrong"})
|
||||
success = await client.post(
|
||||
"/api/v1/session", json={"access_token": "correct horse battery staple"}
|
||||
)
|
||||
after_success = [
|
||||
await client.post("/api/v1/session", json={"access_token": "wrong"})
|
||||
for _ in range(3)
|
||||
]
|
||||
|
||||
assert success.status_code == 200
|
||||
assert [response.status_code for response in after_success] == [401, 401, 401]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_authenticated_get_reaches_private_api(access_control, monkeypatch):
|
||||
async def user():
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import sqlite3
|
||||
|
||||
from src.login_attempt_store import LoginAttemptStore, client_source
|
||||
|
||||
|
||||
def test_forwarded_client_is_used_only_for_explicitly_trusted_proxies():
|
||||
trusted = "127.0.0.0/8, 10.0.0.0/8"
|
||||
|
||||
assert client_source("198.51.100.4", "203.0.113.7", trusted) == "198.51.100.4"
|
||||
assert (
|
||||
client_source("127.0.0.1", "203.0.113.7, 10.1.2.3", trusted)
|
||||
== "203.0.113.7"
|
||||
)
|
||||
assert client_source("127.0.0.1", "not-an-ip", trusted) == "127.0.0.1"
|
||||
|
||||
|
||||
def test_failure_budget_is_shared_across_store_instances_and_expires(tmp_path):
|
||||
now = [1_000.0]
|
||||
database = tmp_path / "login-attempts.sqlite3"
|
||||
first = LoginAttemptStore(
|
||||
database,
|
||||
clock=lambda: now[0],
|
||||
max_failures=3,
|
||||
window_seconds=60,
|
||||
)
|
||||
second = LoginAttemptStore(
|
||||
database,
|
||||
clock=lambda: now[0],
|
||||
max_failures=3,
|
||||
window_seconds=60,
|
||||
)
|
||||
|
||||
assert first.retry_after("203.0.113.7") == 0
|
||||
first.record_failure("203.0.113.7")
|
||||
second.record_failure("203.0.113.7")
|
||||
first.record_failure("203.0.113.7")
|
||||
|
||||
assert second.retry_after("203.0.113.7") == 60
|
||||
now[0] = 1_060.0
|
||||
assert first.retry_after("203.0.113.7") == 0
|
||||
|
||||
|
||||
def test_recording_a_failure_prunes_expired_source_records(tmp_path):
|
||||
now = [1_000.0]
|
||||
store = LoginAttemptStore(
|
||||
tmp_path / "login-attempts.sqlite3",
|
||||
clock=lambda: now[0],
|
||||
max_failures=3,
|
||||
window_seconds=60,
|
||||
)
|
||||
store.record_failure("203.0.113.1")
|
||||
store.record_failure("203.0.113.2")
|
||||
|
||||
now[0] = 1_060.0
|
||||
store.record_failure("203.0.113.3")
|
||||
|
||||
with sqlite3.connect(store.path) as connection:
|
||||
assert connection.execute("SELECT COUNT(*) FROM login_attempts").fetchone() == (1,)
|
||||
|
||||
|
||||
def test_failure_ledger_evicts_oldest_sources_at_its_size_limit(tmp_path):
|
||||
store = LoginAttemptStore(
|
||||
tmp_path / "login-attempts.sqlite3",
|
||||
clock=lambda: 1_000.0,
|
||||
max_failures=3,
|
||||
window_seconds=60,
|
||||
max_entries=2,
|
||||
)
|
||||
|
||||
for address in ("203.0.113.1", "203.0.113.2", "203.0.113.3"):
|
||||
store.record_failure(address)
|
||||
|
||||
with sqlite3.connect(store.path) as connection:
|
||||
assert connection.execute("SELECT COUNT(*) FROM login_attempts").fetchone() == (2,)
|
||||
@@ -0,0 +1,59 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.views import login
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
LOGIN_JS = ROOT / "frontend" / "login.js"
|
||||
|
||||
|
||||
def test_rate_limited_login_disables_submit_and_counts_down():
|
||||
harness = f"""
|
||||
const createLoginController = require({json.dumps(str(LOGIN_JS))});
|
||||
const state = {{ reset: 0, interval: null }};
|
||||
const status = {{ textContent: '' }};
|
||||
const button = {{ disabled: false }};
|
||||
const form = {{ reset: () => state.reset += 1 }};
|
||||
const controller = createLoginController({{
|
||||
form, status, button,
|
||||
fetchImpl: async () => new Response(JSON.stringify({{ detail: 'Too many sign-in attempts' }}), {{ status: 429, headers: {{ 'Retry-After': '2' }} }}),
|
||||
location: {{ replace: () => {{}} }},
|
||||
setIntervalImpl: callback => {{ state.interval = callback; return 1; }},
|
||||
clearIntervalImpl: () => {{}},
|
||||
}});
|
||||
(async () => {{
|
||||
await controller.submit('never-store-this-token');
|
||||
state.initial = {{ disabled: button.disabled, status: status.textContent, reset: state.reset }};
|
||||
state.interval();
|
||||
state.afterTick = {{ disabled: button.disabled, status: status.textContent }};
|
||||
state.interval();
|
||||
state.finished = {{ disabled: button.disabled, status: status.textContent }};
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", harness], text=True, capture_output=True, check=True
|
||||
)
|
||||
state = json.loads(result.stdout)
|
||||
|
||||
assert state["initial"] == {
|
||||
"disabled": True,
|
||||
"status": "Too many attempts. Try again in 2 seconds.",
|
||||
"reset": 1,
|
||||
}
|
||||
assert state["afterTick"]["disabled"] is True
|
||||
assert state["finished"] == {
|
||||
"disabled": False,
|
||||
"status": "You can try signing in again.",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_login_page_loads_rate_limit_controller():
|
||||
html = await login()
|
||||
|
||||
assert '<script src="static/login.js"></script>' in html
|
||||
Reference in new issue
Block a user