From 1cff22658b686002dffe9a45cd6f1fb9f63d2427 Mon Sep 17 00:00:00 2001 From: Daniel Maksymilian Syrnicki Date: Wed, 22 Apr 2026 17:27:14 +0200 Subject: [PATCH] feat(auth): rate-limit failed logins with per-(user, IP) lockout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ten wrong passwords from the same (username, client-IP) tuple within 15 minutes now return 429 with Retry-After for the next 15 minutes; authenticate() isn't even called while locked, so the 429 response is identical whether the password would have been correct — no oracle. Tuple keying prevents an attacker from one IP from locking the real admin out of their own box: a different IP (or an ISP reconnect) keeps them in. The client IP comes from the rightmost X-Forwarded-For entry, which is what Caddy appends and thus trustworthy (no upstream proxy in front of Caddy). First-run setup bypasses the lockout — otherwise a clumsy operator could lock themselves out before an admin exists. State is in-memory (parallel to SessionStore), so `systemctl restart furtka` clears a stuck lockout. Co-Authored-By: Claude Opus 4.7 (1M context) --- furtka/api.py | 30 +++++++++-- furtka/auth.py | 82 +++++++++++++++++++++++++++- tests/test_api.py | 129 ++++++++++++++++++++++++++++++++++++++++++++- tests/test_auth.py | 82 +++++++++++++++++++++++++++- 4 files changed, 315 insertions(+), 8 deletions(-) diff --git a/furtka/api.py b/furtka/api.py index d3a6051..8dc693f 100644 --- a/furtka/api.py +++ b/furtka/api.py @@ -1177,6 +1177,16 @@ class _Handler(BaseHTTPRequestHandler): f"{auth.COOKIE_NAME}=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0", ) + def _client_ip(self) -> str: + # Caddy's reverse_proxy appends the real TCP peer to X-Forwarded-For; + # the rightmost entry is the one Caddy added, so it's trustworthy + # even if a client spoofed an XFF of their own. Caddy is the edge — + # no upstream proxy in front of it. + xff = self.headers.get("X-Forwarded-For") + if xff: + return xff.rsplit(",", 1)[-1].strip() + return self.client_address[0] + def _handle_login(self, payload): username = payload.get("username") if isinstance(payload, dict) else None password = payload.get("password") if isinstance(payload, dict) else None @@ -1200,12 +1210,26 @@ class _Handler(BaseHTTPRequestHandler): ) auth.create_admin(username, password) else: + # Tuple-keyed lockout: a flood from one IP can't lock the + # admin out from a different IP. When locked we return the + # same 429 regardless of whether the password is correct — + # no oracle, no timing leak via "would have worked." + lockout_key = (username, self._client_ip()) + retry = auth.LOCKOUT.retry_after_seconds(lockout_key) + if retry > 0: + return self._json( + 429, + {"error": "too many failed attempts, try again later"}, + extra_headers=[("Retry-After", str(retry))], + ) if not auth.authenticate(username, password): - # Cheap brute-force speed bump. werkzeug's PBKDF2 is - # already slow per attempt, but a fixed sleep makes - # "try 1000 passwords over the LAN" even less fun. + # Register before the sleep so concurrent threads see a + # consistent count; keep the sleep so timing can't + # distinguish "locked" from "wrong password." + auth.LOCKOUT.register_failure(lockout_key) time.sleep(0.5) return self._json(401, {"error": "invalid username or password"}) + auth.LOCKOUT.clear(lockout_key) session = auth.SESSIONS.create(username) cookie = self._session_cookie_header(session.token, auth.COOKIE_TTL_SECONDS) diff --git a/furtka/auth.py b/furtka/auth.py index b14818b..6dbfa35 100644 --- a/furtka/auth.py +++ b/furtka/auth.py @@ -4,7 +4,9 @@ One admin, one password. Passwords are PBKDF2-SHA256 hashed via ``furtka.passwd`` (stdlib-only — hashlib.pbkdf2_hmac / hashlib.scrypt), stored in /var/lib/furtka/users.json with mode 0600. Sessions live in memory — a systemctl restart logs everyone out again, which is fine -for an alpha single-user box. +for an alpha single-user box. The ``LoginAttempts`` store in this +module rate-limits failed logins per (username, IP) and is also +in-memory; a restart clears a stuck lockout. On upgrade from pre-auth Furtka the users.json file does not exist yet; the api's GET /login detects this via ``setup_needed()`` and @@ -20,6 +22,7 @@ forward without re-setup; see ``furtka.passwd`` for the scrypt reader. from __future__ import annotations import json +import math import secrets import threading from dataclasses import dataclass @@ -176,5 +179,82 @@ class SessionStore: self._by_token.clear() +class LoginAttempts: + """In-memory rate-limiter for failed logins, keyed by (username, ip). + + Parallels SessionStore: thread-safe, uses ``datetime.now(UTC)`` so the + same ``_FakeDatetime`` test shim works, lives only in memory so a + ``systemctl restart furtka`` wipes a stuck lockout. Tuple keying means + a flood from one source IP can't lock the admin out from elsewhere + (different IP → different key) — the trade-off is that an attacker + can keep probing forever by rotating IPs, but they still eat the + PBKDF2 cost per attempt. + + Stored data is a dict[key → list[datetime]] of recent failure + timestamps. Every call prunes entries older than ``WINDOW_SECONDS``, + so memory per active key is bounded by ``MAX_FAILURES``. + """ + + MAX_FAILURES = 10 + WINDOW_SECONDS = 15 * 60 + LOCKOUT_SECONDS = 15 * 60 + + def __init__( + self, + max_failures: int = MAX_FAILURES, + window_seconds: int = WINDOW_SECONDS, + lockout_seconds: int = LOCKOUT_SECONDS, + ) -> None: + self._max = max_failures + self._window = timedelta(seconds=window_seconds) + self._lockout = timedelta(seconds=lockout_seconds) + self._fails: dict[tuple[str, str], list[datetime]] = {} + self._lock = threading.Lock() + + def _prune_locked(self, key: tuple[str, str], now: datetime) -> list[datetime]: + """Drop timestamps older than the window; caller holds self._lock.""" + cutoff = now - self._window + kept = [ts for ts in self._fails.get(key, ()) if ts >= cutoff] + if kept: + self._fails[key] = kept + else: + self._fails.pop(key, None) + return kept + + def register_failure(self, key: tuple[str, str]) -> None: + now = datetime.now(UTC) + with self._lock: + self._prune_locked(key, now) + self._fails.setdefault(key, []).append(now) + + def is_locked(self, key: tuple[str, str]) -> bool: + return self.retry_after_seconds(key) > 0 + + def retry_after_seconds(self, key: tuple[str, str]) -> int: + """Seconds remaining on an active lockout, or 0 if not locked.""" + now = datetime.now(UTC) + with self._lock: + kept = self._prune_locked(key, now) + if len(kept) < self._max: + return 0 + # Lockout runs from the oldest retained failure; once it + # falls off the window the key is effectively released. + unlock_at = kept[0] + self._lockout + remaining = (unlock_at - now).total_seconds() + if remaining <= 0: + return 0 + return max(1, math.ceil(remaining)) + + def clear(self, key: tuple[str, str]) -> None: + with self._lock: + self._fails.pop(key, None) + + def clear_all(self) -> None: + """Test helper — wipe all failure state.""" + with self._lock: + self._fails.clear() + + # Module-level singleton used by the HTTP handler. SESSIONS = SessionStore() +LOCKOUT = LoginAttempts() diff --git a/tests/test_api.py b/tests/test_api.py index 248c44e..b75c9a4 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -48,9 +48,10 @@ def fake_dirs(tmp_path, monkeypatch): from furtka import install_runner importlib.reload(install_runner) - # Scrub any sessions that leaked from a prior test — the SESSIONS - # store is module-level. + # Scrub any sessions or lockout counters that leaked from a prior + # test — both stores are module-level. auth.SESSIONS.clear() + auth.LOCKOUT.clear_all() return apps, bundled @@ -600,6 +601,130 @@ def test_post_login_rejects_wrong_password(fake_dirs): server.server_close() +def _post_wrong_login(port, username="daniel", password="nope"): + req = _request( + port, + "/login", + method="POST", + body={"username": username, "password": password}, + ) + try: + urllib.request.urlopen(req) + raise AssertionError("expected HTTPError") + except urllib.error.HTTPError as e: + return e + + +def test_post_login_locks_out_after_repeated_failures(fake_dirs, monkeypatch): + auth.create_admin("daniel", "hunter2-pw") + # Flatten the 0.5s speed-bump so the test doesn't take 5 seconds. + monkeypatch.setattr(api.time, "sleep", lambda _s: None) + server, port = _start_server() + try: + for _ in range(auth.LoginAttempts.MAX_FAILURES): + err = _post_wrong_login(port) + assert err.code == 401 + err = _post_wrong_login(port) + assert err.code == 429 + assert err.headers.get("Retry-After") is not None + assert int(err.headers["Retry-After"]) > 0 + finally: + server.shutdown() + server.server_close() + + +def test_post_login_429_masks_correctness(fake_dirs, monkeypatch): + """Once locked, the correct password must also get 429 — no oracle.""" + auth.create_admin("daniel", "hunter2-pw") + monkeypatch.setattr(api.time, "sleep", lambda _s: None) + server, port = _start_server() + try: + for _ in range(auth.LoginAttempts.MAX_FAILURES): + _post_wrong_login(port) + req = _request( + port, + "/login", + method="POST", + body={"username": "daniel", "password": "hunter2-pw"}, + ) + try: + urllib.request.urlopen(req) + raise AssertionError("expected 429") + except urllib.error.HTTPError as e: + assert e.code == 429 + finally: + server.shutdown() + server.server_close() + + +def test_post_login_success_clears_lockout_counter(fake_dirs, monkeypatch): + auth.create_admin("daniel", "hunter2-pw") + monkeypatch.setattr(api.time, "sleep", lambda _s: None) + server, port = _start_server() + try: + # Get close to the threshold, then log in successfully. + for _ in range(auth.LoginAttempts.MAX_FAILURES - 1): + _post_wrong_login(port) + req = _request( + port, + "/login", + method="POST", + body={"username": "daniel", "password": "hunter2-pw"}, + ) + with urllib.request.urlopen(req) as r: + assert r.status == 200 + # Counter must have been cleared: another full MAX_FAILURES-1 + # fails shouldn't trigger 429. + for _ in range(auth.LoginAttempts.MAX_FAILURES - 1): + err = _post_wrong_login(port) + assert err.code == 401 + finally: + server.shutdown() + server.server_close() + + +def test_post_login_setup_not_rate_limited(fake_dirs, monkeypatch): + """First-run setup is never auth-ed against a hash, so the lockout + must not apply — otherwise a clumsy admin could lock themselves out + of a box that has no admin yet.""" + monkeypatch.setattr(api.time, "sleep", lambda _s: None) + server, port = _start_server() + try: + # Many mismatched setup submissions (400s) — no 429 should appear. + for _ in range(auth.LoginAttempts.MAX_FAILURES + 3): + req = _request( + port, + "/login", + method="POST", + body={ + "username": "daniel", + "password": "longenough", + "password2": "different", + }, + ) + try: + urllib.request.urlopen(req) + raise AssertionError("expected 400") + except urllib.error.HTTPError as e: + assert e.code == 400 + # Then a good setup still succeeds. + req = _request( + port, + "/login", + method="POST", + body={ + "username": "daniel", + "password": "longenough", + "password2": "longenough", + }, + ) + with urllib.request.urlopen(req) as r: + assert r.status == 200 + finally: + server.shutdown() + server.server_close() + + def test_post_logout_revokes_session(fake_dirs, admin_session): server, port = _start_server() try: diff --git a/tests/test_auth.py b/tests/test_auth.py index 21bea43..c4c2fcb 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -10,9 +10,11 @@ from furtka import auth def tmp_users_file(tmp_path, monkeypatch): path = tmp_path / "users.json" monkeypatch.setenv("FURTKA_USERS_FILE", str(path)) - # Sessions are module-level; wipe between tests so one doesn't leak a - # valid token into the next. + # Sessions and lockout state are module-level; wipe between tests so + # one doesn't leak a valid token (or a stale failure counter) into + # the next. auth.SESSIONS.clear() + auth.LOCKOUT.clear_all() return path @@ -150,3 +152,79 @@ class _FakeDatetime: if tz is None: return self._fixed.replace(tzinfo=None) return self._fixed.astimezone(tz) + + +# ---- Login attempts / lockout ---------------------------------------------- + + +def test_lockout_under_threshold_still_allowed(tmp_users_file): + store = auth.LoginAttempts(max_failures=3, window_seconds=60, lockout_seconds=60) + key = ("daniel", "10.0.0.1") + for _ in range(2): + store.register_failure(key) + assert store.is_locked(key) is False + assert store.retry_after_seconds(key) == 0 + + +def test_lockout_triggers_at_threshold(tmp_users_file): + store = auth.LoginAttempts(max_failures=3, window_seconds=60, lockout_seconds=60) + key = ("daniel", "10.0.0.1") + for _ in range(3): + store.register_failure(key) + assert store.is_locked(key) is True + assert store.retry_after_seconds(key) > 0 + assert store.retry_after_seconds(key) <= 60 + + +def test_lockout_window_decay(tmp_users_file, monkeypatch): + store = auth.LoginAttempts(max_failures=3, window_seconds=60, lockout_seconds=60) + key = ("daniel", "10.0.0.1") + for _ in range(3): + store.register_failure(key) + assert store.is_locked(key) is True + # Jump 2 minutes ahead — all failures are older than the window + # and should be pruned on the next check. + monkeypatch.setattr( + auth, + "datetime", + _FakeDatetime(datetime.now(UTC) + timedelta(seconds=121)), + ) + assert store.is_locked(key) is False + assert store.retry_after_seconds(key) == 0 + + +def test_lockout_clear_resets(tmp_users_file): + store = auth.LoginAttempts(max_failures=2, window_seconds=60, lockout_seconds=60) + key = ("daniel", "10.0.0.1") + store.register_failure(key) + store.register_failure(key) + assert store.is_locked(key) is True + store.clear(key) + assert store.is_locked(key) is False + assert store.retry_after_seconds(key) == 0 + + +def test_lockout_keys_are_independent(tmp_users_file): + store = auth.LoginAttempts(max_failures=2, window_seconds=60, lockout_seconds=60) + locked = ("daniel", "1.1.1.1") + other_ip = ("daniel", "2.2.2.2") + other_user = ("robert", "1.1.1.1") + store.register_failure(locked) + store.register_failure(locked) + assert store.is_locked(locked) is True + assert store.is_locked(other_ip) is False + assert store.is_locked(other_user) is False + + +def test_lockout_clear_all_wipes_every_key(tmp_users_file): + store = auth.LoginAttempts(max_failures=2, window_seconds=60, lockout_seconds=60) + a = ("daniel", "1.1.1.1") + b = ("robert", "2.2.2.2") + store.register_failure(a) + store.register_failure(a) + store.register_failure(b) + store.register_failure(b) + assert store.is_locked(a) and store.is_locked(b) + store.clear_all() + assert not store.is_locked(a) + assert not store.is_locked(b)