feat(security): implement IP rate limiting and secure session cookies on Hub
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 1m46s
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 1m46s
This commit is contained in:
+1
-1
Submodule LOGAR-LICENSING updated: 5fdd53cb0a...a2d3c86e2c
+67
-1
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user