feat: throttle repeated operator sign-in failures (#270)
CI / lint (pull_request) Successful in 26s
CI / build-frontend (pull_request) Successful in 7s

This commit is contained in:
timmy committed 2026-08-08 06:19:08 +00:00
1 parent 334fe77e84
commit 87becb08ad
8 files changed
+471 -2

No files matched your search

+149
View File
@@ -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
+55
View File
@@ -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:
+2 -2
View File
@@ -13,8 +13,8 @@ LOGIN_HTML = """<!doctype html>
<title>Sign in · Stackchain Dashboard</title>
<style>body{margin:0;background:#07111f;color:#eef6ff;font:16px system-ui;display:grid;min-height:100vh;place-items:center}main{width:min(90vw,24rem);padding:2rem;border:1px solid #29415d;border-radius:1rem;background:#0d1b2b}label,input,button{display:block;width:100%;box-sizing:border-box}input,button{min-height:48px;margin-top:.6rem;border-radius:.6rem;border:1px solid #49647f;padding:.75rem}button{margin-top:1rem;background:#55d6be;color:#06121b;font-weight:700}p{color:#a9bed3}</style></head>
<body><main><h1>Operator sign in</h1><p>Enter the dashboard access token. It is exchanged for a private, short-lived session and is never stored on this device.</p>
<form id="sign-in"><label>Access token<input name="access_token" type="password" autocomplete="current-password" required></label><button>Sign in</button><p id="status" role="status"></p></form></main>
<script>document.getElementById('sign-in').addEventListener('submit',async(event)=>{event.preventDefault();const status=document.getElementById('status');status.textContent='Signing in…';const access_token=new FormData(event.currentTarget).get('access_token');const response=await fetch('api/v1/session',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({access_token})});if(response.ok){location.replace('./');return}status.textContent='Sign-in failed. Check the token and try again.';event.currentTarget.reset()});</script></body></html>"""
<form id="sign-in"><label>Access token<input name="access_token" type="password" autocomplete="current-password" required></label><button id="submit-sign-in">Sign in</button><p id="status" role="status"></p></form></main>
<script src="static/login.js"></script></body></html>"""
class RevalidatingHTMLResponse(HTMLResponse):