diff --git a/README.md b/README.md index dd3a845..24d9c7b 100644 --- a/README.md +++ b/README.md @@ -63,9 +63,25 @@ export STACKCHAIN_DASHBOARD_SESSION_SECRET='' export STACKCHAIN_SESSION_DB='/var/lib/stackchain-dashboard/sessions.sqlite3' # Optional; defaults to eight hours. export STACKCHAIN_DASHBOARD_SESSION_TTL_SECONDS=28800 +# Optional sign-in throttle: five failures per five minutes, up to 10,000 sources. +export STACKCHAIN_LOGIN_MAX_FAILURES=5 +export STACKCHAIN_LOGIN_WINDOW_SECONDS=300 +export STACKCHAIN_LOGIN_MAX_ENTRIES=10000 +# Optional; defaults to STACKCHAIN_STATE_DIR/login-attempts.sqlite3. +export STACKCHAIN_LOGIN_ATTEMPT_DB='/var/lib/stackchain-dashboard/login-attempts.sqlite3' +# Trust forwarding headers only from these immediate reverse-proxy networks. +export STACKCHAIN_TRUSTED_PROXY_CIDRS='127.0.0.0/8' uvicorn src.main:app --host 127.0.0.1 --port 8000 ``` +Sign-in failures are scoped to a hashed canonical client address and persisted across +workers and restarts. Once the budget is exhausted, the server returns `429` with +`Retry-After`; the mobile login form disables retries for that interval. Expired +source records are pruned and the ledger is size-bounded. Keep its SQLite file on +shared writable storage. `X-Forwarded-For` is ignored unless the immediate peer is +inside `STACKCHAIN_TRUSTED_PROXY_CIDRS`; list only networks you operate. Without +that setting, a reverse proxy is safely treated as one shared source. + Each signed cookie includes an opaque session identifier whose hash and expiry are kept in the SQLite session registry. Keep that registry on persistent, writable storage shared by all dashboard workers. Sign-out revokes only the current session diff --git a/frontend/login.js b/frontend/login.js new file mode 100644 index 0000000..594d73f --- /dev/null +++ b/frontend/login.js @@ -0,0 +1,76 @@ +(function (root, factory) { + if (typeof module === 'object' && module.exports) module.exports = factory; + else root.createLoginController = factory; +}(typeof self !== 'undefined' ? self : this, function createLoginController(options) { + const form = options.form; + const status = options.status; + const button = options.button; + const fetchImpl = options.fetchImpl; + const location = options.location; + const setIntervalImpl = options.setIntervalImpl || setInterval; + const clearIntervalImpl = options.clearIntervalImpl || clearInterval; + let timer = null; + + function showRetryCountdown(seconds) { + let remaining = Math.max(1, Number.parseInt(seconds, 10) || 1); + button.disabled = true; + status.textContent = `Too many attempts. Try again in ${remaining} seconds.`; + timer = setIntervalImpl(() => { + remaining -= 1; + if (remaining <= 0) { + clearIntervalImpl(timer); + timer = null; + button.disabled = false; + status.textContent = 'You can try signing in again.'; + return; + } + status.textContent = `Too many attempts. Try again in ${remaining} seconds.`; + }, 1000); + } + + async function submit(accessToken) { + status.textContent = 'Signing in…'; + let response; + try { + response = await fetchImpl('api/v1/session', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ access_token: accessToken }), + }); + } catch (_error) { + form.reset(); + status.textContent = 'Sign-in failed. Check your connection and try again.'; + return; + } + form.reset(); + if (response.ok) { + location.replace('./'); + return; + } + if (response.status === 429) { + showRetryCountdown(response.headers.get('Retry-After')); + return; + } + status.textContent = 'Sign-in failed. Check the token and try again.'; + } + + return { submit }; +})); + +if (typeof document !== 'undefined') { + const form = document.getElementById('sign-in'); + const status = document.getElementById('status'); + const button = document.getElementById('submit-sign-in'); + const controller = createLoginController({ + form, + status, + button, + fetchImpl: fetch.bind(window), + location: window.location, + }); + form.addEventListener('submit', event => { + event.preventDefault(); + const accessToken = new FormData(form).get('access_token'); + controller.submit(accessToken); + }); +} diff --git a/src/login_attempt_store.py b/src/login_attempt_store.py new file mode 100644 index 0000000..4cd4427 --- /dev/null +++ b/src/login_attempt_store.py @@ -0,0 +1,149 @@ +"""Durable throttling state for operator sign-in attempts.""" + +import hashlib +import ipaddress +import math +import sqlite3 +from pathlib import Path +from typing import Callable + + +class LoginAttemptStoreError(RuntimeError): + """Raised when sign-in throttling state cannot be accessed safely.""" + + +def client_source(peer_host: str, forwarded_for: str, trusted_proxy_cidrs: str) -> str: + """Resolve a canonical client IP without trusting arbitrary forwarding headers.""" + try: + peer = ipaddress.ip_address(peer_host) + trusted = [ + ipaddress.ip_network(value.strip()) + for value in trusted_proxy_cidrs.split(",") + if value.strip() + ] + except ValueError: + return peer_host + if not any(peer in network for network in trusted): + return str(peer) + try: + forwarded = [ + ipaddress.ip_address(value.strip()) + for value in forwarded_for.split(",") + if value.strip() + ] + except ValueError: + return str(peer) + for address in reversed(forwarded): + if not any(address in network for network in trusted): + return str(address) + return str(forwarded[0]) if forwarded else str(peer) + + +class LoginAttemptStore: + def __init__( + self, + path: str | Path, + *, + clock: Callable[[], float], + max_failures: int, + window_seconds: int, + max_entries: int = 10_000, + lock_timeout_seconds: float = 0.1, + ) -> None: + self.path = Path(path) + self.clock = clock + self.max_failures = max(1, max_failures) + self.window_seconds = max(1, window_seconds) + self.max_entries = max(1, max_entries) + self.lock_timeout_seconds = lock_timeout_seconds + + @staticmethod + def _digest(source: str) -> str: + return hashlib.sha256(source.encode()).hexdigest() + + def _connect(self) -> sqlite3.Connection: + try: + self.path.parent.mkdir(parents=True, exist_ok=True) + connection = sqlite3.connect(self.path, timeout=self.lock_timeout_seconds) + connection.execute( + """ + CREATE TABLE IF NOT EXISTS login_attempts ( + source_hash TEXT PRIMARY KEY, + failures INTEGER NOT NULL, + window_started_at REAL NOT NULL + ) + """ + ) + return connection + except (OSError, sqlite3.Error) as exc: + raise LoginAttemptStoreError( + "Sign-in throttling is temporarily unavailable" + ) from exc + + def retry_after(self, source: str) -> int: + now = self.clock() + try: + with self._connect() as connection: + row = connection.execute( + "SELECT failures, window_started_at FROM login_attempts WHERE source_hash = ?", + (self._digest(source),), + ).fetchone() + except (OSError, sqlite3.Error) as exc: + raise LoginAttemptStoreError( + "Sign-in throttling is temporarily unavailable" + ) from exc + if row is None or row[0] < self.max_failures: + return 0 + remaining = row[1] + self.window_seconds - now + return max(0, math.ceil(remaining)) + + def record_failure(self, source: str) -> None: + now = self.clock() + source_hash = self._digest(source) + try: + with self._connect() as connection: + connection.execute("BEGIN IMMEDIATE") + connection.execute( + "DELETE FROM login_attempts WHERE window_started_at + ? <= ?", + (self.window_seconds, now), + ) + row = connection.execute( + "SELECT failures, window_started_at FROM login_attempts WHERE source_hash = ?", + (source_hash,), + ).fetchone() + if row is None or row[1] + self.window_seconds <= now: + connection.execute( + "INSERT OR REPLACE INTO login_attempts VALUES (?, 1, ?)", + (source_hash, now), + ) + else: + connection.execute( + "UPDATE login_attempts SET failures = failures + 1 WHERE source_hash = ?", + (source_hash,), + ) + connection.execute( + """ + DELETE FROM login_attempts + WHERE source_hash NOT IN ( + SELECT source_hash FROM login_attempts + ORDER BY window_started_at DESC, rowid DESC LIMIT ? + ) + """, + (self.max_entries,), + ) + except (OSError, sqlite3.Error) as exc: + raise LoginAttemptStoreError( + "Sign-in throttling is temporarily unavailable" + ) from exc + + def clear(self, source: str) -> None: + try: + with self._connect() as connection: + connection.execute( + "DELETE FROM login_attempts WHERE source_hash = ?", + (self._digest(source),), + ) + except (OSError, sqlite3.Error) as exc: + raise LoginAttemptStoreError( + "Sign-in throttling is temporarily unavailable" + ) from exc diff --git a/src/main.py b/src/main.py index 4c42abd..b439e04 100644 --- a/src/main.py +++ b/src/main.py @@ -28,6 +28,7 @@ from src.gitea_proxy import ( repos, ) from src.idempotency import IdempotencyLedger, IdempotencyLedgerBusy +from src.login_attempt_store import LoginAttemptStore, LoginAttemptStoreError, client_source from src.models import Issue, Milestone, PullRequest, Repo, User from src.suggestion_engine import compute from src.views import router as frontend_router @@ -129,6 +130,20 @@ class DashboardSignIn(BaseModel): access_token: str = Field(min_length=1, max_length=1_024) +def _login_attempt_store() -> LoginAttemptStore: + state_dir = os.getenv("STACKCHAIN_STATE_DIR", ".stackchain-state") + return LoginAttemptStore( + os.getenv( + "STACKCHAIN_LOGIN_ATTEMPT_DB", + os.path.join(state_dir, "login-attempts.sqlite3"), + ), + clock=time.time, + max_failures=int(os.getenv("STACKCHAIN_LOGIN_MAX_FAILURES", "5")), + window_seconds=int(os.getenv("STACKCHAIN_LOGIN_WINDOW_SECONDS", "300")), + max_entries=int(os.getenv("STACKCHAIN_LOGIN_MAX_ENTRIES", "10000")), + ) + + class NotificationReadBatch(BaseModel): ids: list[PositiveInt] = Field(min_length=1, max_length=50) @@ -521,11 +536,51 @@ def health() -> dict[str, str]: @app.post("/api/v1/session") async def sign_in(payload: DashboardSignIn, request: Request, response: Response): + peer_host = request.client.host if request.client is not None else "unknown" + source = client_source( + peer_host, + request.headers.get("x-forwarded-for", ""), + os.getenv("STACKCHAIN_TRUSTED_PROXY_CIDRS", ""), + ) + attempts = _login_attempt_store() + try: + retry_after = attempts.retry_after(source) + except LoginAttemptStoreError: + return JSONResponse( + {"detail": "Sign-in throttling is temporarily unavailable"}, + status_code=503, + headers={"Cache-Control": "no-store"}, + ) + if retry_after: + return JSONResponse( + {"detail": "Too many sign-in attempts"}, + status_code=429, + headers={ + "Cache-Control": "no-store", + "Retry-After": str(retry_after), + }, + ) configured_token = dashboard_auth.access_token() if not configured_token or not hmac.compare_digest( payload.access_token, configured_token ): + try: + attempts.record_failure(source) + except LoginAttemptStoreError: + return JSONResponse( + {"detail": "Sign-in throttling is temporarily unavailable"}, + status_code=503, + headers={"Cache-Control": "no-store"}, + ) raise HTTPException(status_code=401, detail="Invalid access token") + try: + attempts.clear(source) + except LoginAttemptStoreError: + return JSONResponse( + {"detail": "Sign-in throttling is temporarily unavailable"}, + status_code=503, + headers={"Cache-Control": "no-store"}, + ) try: signed, session = dashboard_auth.issue_session() except dashboard_auth.SessionStoreError: diff --git a/src/views.py b/src/views.py index 8db9179..e285f37 100644 --- a/src/views.py +++ b/src/views.py @@ -13,8 +13,8 @@ LOGIN_HTML = """ Sign in · Stackchain Dashboard

Operator sign in

Enter the dashboard access token. It is exchanged for a private, short-lived session and is never stored on this device.

-

-""" +

+""" class RevalidatingHTMLResponse(HTMLResponse): diff --git a/tests/test_dashboard_auth.py b/tests/test_dashboard_auth.py index b2cf937..23afc9a 100644 --- a/tests/test_dashboard_auth.py +++ b/tests/test_dashboard_auth.py @@ -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(): diff --git a/tests/test_login_attempt_store.py b/tests/test_login_attempt_store.py new file mode 100644 index 0000000..22bad2c --- /dev/null +++ b/tests/test_login_attempt_store.py @@ -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,) diff --git a/tests/test_login_frontend.py b/tests/test_login_frontend.py new file mode 100644 index 0000000..1f1dc7e --- /dev/null +++ b/tests/test_login_frontend.py @@ -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 '' in html