feat(security): implement IP rate limiting and secure session cookies on Hub
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 1m46s

This commit is contained in:
2026-09-25 23:50:15 +02:00
parent ad022cc447
commit e04082d4b8
2 changed files with 68 additions and 2 deletions
+67 -1
View File
@@ -10,6 +10,7 @@ import asyncio
import secrets import secrets
import hashlib import hashlib
import hmac import hmac
import time
import warnings import warnings
import threading import threading
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
@@ -88,6 +89,54 @@ class ChangePasswordRequest(BaseModel):
new_password: str new_password: str
# --- Rate Limiting & Brute-Force Mitigation ---
_RATE_LIMIT_LOCK = threading.Lock()
_LOGIN_FAILURES: Dict[str, List[float]] = {}
_ENROLL_FAILURES: Dict[str, List[float]] = {}
def get_client_ip(request: Optional[Request] = None) -> str:
"""Extracts client IP address respecting reverse-proxy headers."""
if not request:
return "127.0.0.1"
forwarded = request.headers.get("x-forwarded-for")
if forwarded:
return forwarded.split(",")[0].strip()
real_ip = request.headers.get("x-real-ip")
if real_ip:
return real_ip.strip()
if request.client:
return request.client.host
return "127.0.0.1"
def check_ip_rate_limit(ip: str, store: Dict[str, List[float]], max_attempts: int = 10, window_seconds: int = 60) -> bool:
"""Checks whether the client IP has exceeded failed attempts within the sliding time window."""
now = datetime.now(timezone.utc).timestamp()
with _RATE_LIMIT_LOCK:
history = store.get(ip, [])
history = [ts for ts in history if now - ts < window_seconds]
store[ip] = history
return len(history) < max_attempts
def record_failed_ip_attempt(ip: str, store: Dict[str, List[float]]):
"""Records a failed attempt timestamp for the client IP."""
now = datetime.now(timezone.utc).timestamp()
with _RATE_LIMIT_LOCK:
if ip not in store:
store[ip] = []
store[ip].append(now)
def reset_failed_ip_attempts(ip: str, store: Dict[str, List[float]]):
"""Resets failed attempt records for client IP upon successful authentication."""
with _RATE_LIMIT_LOCK:
if ip in store:
del store[ip]
# --- Authentication & Password Hashing Utilities --- # --- Authentication & Password Hashing Utilities ---
def hash_password(password: str, salt: Optional[str] = None) -> Tuple[str, str]: def hash_password(password: str, salt: Optional[str] = None) -> Tuple[str, str]:
@@ -1068,6 +1117,10 @@ def login_page(request: Request):
@app.post("/login") @app.post("/login")
async def process_login(request: Request): async def process_login(request: Request):
"""Processes login credentials and issues session cookie.""" """Processes login credentials and issues session cookie."""
client_ip = get_client_ip(request)
if not check_ip_rate_limit(client_ip, _LOGIN_FAILURES, max_attempts=10, window_seconds=60):
raise HTTPException(status_code=429, detail="Too many failed login attempts. Please try again later.")
username, password = None, None username, password = None, None
content_type = request.headers.get("content-type", "") content_type = request.headers.get("content-type", "")
if "application/json" in content_type: if "application/json" in content_type:
@@ -1087,14 +1140,19 @@ async def process_login(request: Request):
db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE) db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE)
if not verify_user_credentials(username, password, db_path=db_path): if not verify_user_credentials(username, password, db_path=db_path):
record_failed_ip_attempt(client_ip, _LOGIN_FAILURES)
await asyncio.sleep(0.5)
raise HTTPException(status_code=401, detail="Invalid username or password") raise HTTPException(status_code=401, detail="Invalid username or password")
reset_failed_ip_attempts(client_ip, _LOGIN_FAILURES)
token = generate_session_token(username) token = generate_session_token(username)
is_https = request.url.scheme == "https" or request.headers.get("x-forwarded-proto") == "https"
response = JSONResponse(content={"status": "success", "username": username, "token": token}) response = JSONResponse(content={"status": "success", "username": username, "token": token})
response.set_cookie( response.set_cookie(
key=SESSION_COOKIE_NAME, key=SESSION_COOKIE_NAME,
value=token, value=token,
httponly=True, httponly=True,
secure=is_https,
max_age=86400, max_age=86400,
samesite="lax" samesite="lax"
) )
@@ -1122,12 +1180,16 @@ async def api_change_password(req: ChangePasswordRequest, current_user: str = De
@app.post("/api/client/enroll") @app.post("/api/client/enroll")
def enroll_client(req: ClientEnrollRequest): def enroll_client(req: ClientEnrollRequest, request: Request = None):
""" """
Enrolls an edge client by validating the enrollment secret, Enrolls an edge client by validating the enrollment secret,
checking license seat limits, issuing a signed client certificate + key, checking license seat limits, issuing a signed client certificate + key,
and recording the client in the SQLite accounting database. and recording the client in the SQLite accounting database.
""" """
client_ip = get_client_ip(request)
if not check_ip_rate_limit(client_ip, _ENROLL_FAILURES, max_attempts=15, window_seconds=60):
raise HTTPException(status_code=429, detail="Too many failed enrollment attempts. Please try again later.")
db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE) db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE)
conn = sqlite3.connect(db_path) conn = sqlite3.connect(db_path)
c = conn.cursor() c = conn.cursor()
@@ -1142,8 +1204,12 @@ def enroll_client(req: ClientEnrollRequest):
expected_secret, max_seats = row expected_secret, max_seats = row
if not secrets.compare_digest(str(req.enrollment_secret), str(expected_secret)): if not secrets.compare_digest(str(req.enrollment_secret), str(expected_secret)):
conn.close() conn.close()
record_failed_ip_attempt(client_ip, _ENROLL_FAILURES)
time.sleep(0.5)
raise HTTPException(status_code=403, detail="Invalid enrollment secret") raise HTTPException(status_code=403, detail="Invalid enrollment secret")
reset_failed_ip_attempts(client_ip, _ENROLL_FAILURES)
ca_cert = SERVER_STATE.get("ca_cert") ca_cert = SERVER_STATE.get("ca_cert")
ca_key = SERVER_STATE.get("ca_key") ca_key = SERVER_STATE.get("ca_key")
ca_cert_pem = SERVER_STATE.get("ca_cert_pem") ca_cert_pem = SERVER_STATE.get("ca_cert_pem")