feat(security): implement persistent session secret and DB-backed sliding-window rate limiting
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 1m44s

This commit is contained in:
2026-09-30 15:55:17 +02:00
parent 9f999fc6c3
commit 2a04244db3
3 changed files with 179 additions and 34 deletions
+133 -25
View File
@@ -76,6 +76,40 @@ EVALUATION_WINDOW_HOURS = 12
RUN_THRESHOLD = 1
SESSION_COOKIE_NAME = "logar_server_session"
_CACHED_SESSION_SECRETS: Dict[str, str] = {}
def get_session_secret(db_path: Optional[str] = None) -> str:
"""Retrieves or persists a cryptographically secure session secret in the database."""
env_sec = os.environ.get("LOGAR_SESSION_SECRET")
if env_sec:
return env_sec
target_db = db_path or SERVER_STATE.get("config", {}).get("db_path") or DEFAULT_DB_FILE
abs_db = os.path.abspath(target_db)
if abs_db in _CACHED_SESSION_SECRETS:
return _CACHED_SESSION_SECRETS[abs_db]
try:
conn = sqlite3.connect(target_db, timeout=5.0)
c = conn.cursor()
c.execute("CREATE TABLE IF NOT EXISTS server_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)")
c.execute("SELECT value FROM server_meta WHERE key = 'session_secret'")
row = c.fetchone()
if row and row[0]:
_CACHED_SESSION_SECRETS[abs_db] = row[0]
conn.close()
return row[0]
new_secret = secrets.token_hex(32)
c.execute("INSERT OR REPLACE INTO server_meta (key, value) VALUES ('session_secret', ?)", (new_secret,))
conn.commit()
conn.close()
_CACHED_SESSION_SECRETS[abs_db] = new_secret
return new_secret
except Exception:
fallback = secrets.token_hex(32)
_CACHED_SESSION_SECRETS[abs_db] = fallback
return fallback
SESSION_SECRET = os.environ.get("LOGAR_SESSION_SECRET") or secrets.token_hex(32)
DEFAULT_ADMIN_USER = os.environ.get("LOGAR_ADMIN_USER", "admin")
DEFAULT_ADMIN_PASS = os.environ.get("LOGAR_ADMIN_PASS", "admin_logar_2026!")
@@ -143,29 +177,81 @@ def get_client_ip(request: Optional[Request] = None) -> str:
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."""
def check_ip_rate_limit(
ip: str,
store: Any = None,
max_attempts: int = 10,
window_seconds: int = 60,
scope: str = "login",
db_path: Optional[str] = None
) -> bool:
"""Checks whether the client IP has exceeded failed attempts within the sliding time window using persistent DB storage."""
now = datetime.now(timezone.utc).timestamp()
cutoff = now - window_seconds
target_db = db_path or SERVER_STATE.get("config", {}).get("db_path") or DEFAULT_DB_FILE
try:
conn = sqlite3.connect(target_db, timeout=5.0)
c = conn.cursor()
c.execute("CREATE TABLE IF NOT EXISTS rate_limits (ip TEXT NOT NULL, scope TEXT NOT NULL, attempt_time REAL NOT NULL)")
c.execute("DELETE FROM rate_limits WHERE attempt_time < ?", (cutoff,))
c.execute("SELECT COUNT(*) FROM rate_limits WHERE ip = ? AND scope = ? AND attempt_time >= ?", (ip, scope, cutoff))
cnt = c.fetchone()[0]
conn.commit()
conn.close()
return cnt < max_attempts
except Exception:
with _RATE_LIMIT_LOCK:
if isinstance(store, dict):
history = store.get(ip, [])
history = [ts for ts in history if now - ts < window_seconds]
store[ip] = history
return len(history) < max_attempts
return True
def record_failed_ip_attempt(ip: str, store: Dict[str, List[float]]):
"""Records a failed attempt timestamp for the client IP."""
def record_failed_ip_attempt(
ip: str,
store: Any = None,
scope: str = "login",
db_path: Optional[str] = None
):
"""Records a persistent failed attempt timestamp for the client IP and scope."""
now = datetime.now(timezone.utc).timestamp()
target_db = db_path or SERVER_STATE.get("config", {}).get("db_path") or DEFAULT_DB_FILE
try:
conn = sqlite3.connect(target_db, timeout=5.0)
c = conn.cursor()
c.execute("CREATE TABLE IF NOT EXISTS rate_limits (ip TEXT NOT NULL, scope TEXT NOT NULL, attempt_time REAL NOT NULL)")
c.execute("INSERT INTO rate_limits (ip, scope, attempt_time) VALUES (?, ?, ?)", (ip, scope, now))
conn.commit()
conn.close()
except Exception:
pass
with _RATE_LIMIT_LOCK:
if isinstance(store, dict):
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."""
def reset_failed_ip_attempts(
ip: str,
store: Any = None,
scope: str = "login",
db_path: Optional[str] = None
):
"""Resets persistent failed attempt records for client IP upon successful authentication."""
target_db = db_path or SERVER_STATE.get("config", {}).get("db_path") or DEFAULT_DB_FILE
try:
conn = sqlite3.connect(target_db, timeout=5.0)
c = conn.cursor()
c.execute("DELETE FROM rate_limits WHERE ip = ? AND scope = ?", (ip, scope))
conn.commit()
conn.close()
except Exception:
pass
with _RATE_LIMIT_LOCK:
if ip in store:
if isinstance(store, dict) and ip in store:
del store[ip]
@@ -185,16 +271,17 @@ def verify_password(password: str, salt: str, password_hash: str) -> bool:
return hmac.compare_digest(test_hash, password_hash)
def generate_session_token(username: str) -> str:
def generate_session_token(username: str, db_path: Optional[str] = None) -> str:
"""Generates an HMAC-signed session cookie string."""
ts = int(datetime.now(timezone.utc).timestamp())
nonce = secrets.token_hex(8)
payload = f"{username}:{ts}:{nonce}"
sig = hmac.new(SESSION_SECRET.encode("utf-8"), payload.encode("utf-8"), hashlib.sha256).hexdigest()
secret = get_session_secret(db_path)
sig = hmac.new(secret.encode("utf-8"), payload.encode("utf-8"), hashlib.sha256).hexdigest()
return f"{payload}:{sig}"
def verify_session_token(token: str, max_age_hours: int = 24) -> Optional[str]:
def verify_session_token(token: Optional[str], max_age_hours: int = 24, db_path: Optional[str] = None) -> Optional[str]:
"""Verifies session token authenticity and age."""
if not token or ":" not in token:
return None
@@ -203,7 +290,8 @@ def verify_session_token(token: str, max_age_hours: int = 24) -> Optional[str]:
return None
username, ts_str, nonce, sig = parts
payload = f"{username}:{ts_str}:{nonce}"
expected_sig = hmac.new(SESSION_SECRET.encode("utf-8"), payload.encode("utf-8"), hashlib.sha256).hexdigest()
secret = get_session_secret(db_path)
expected_sig = hmac.new(secret.encode("utf-8"), payload.encode("utf-8"), hashlib.sha256).hexdigest()
if not hmac.compare_digest(sig, expected_sig):
return None
try:
@@ -217,15 +305,16 @@ def verify_session_token(token: str, max_age_hours: int = 24) -> Optional[str]:
def get_current_user_optional(request: Request) -> Optional[str]:
"""Extracts authenticated username from cookie or Bearer header if valid."""
db_path = SERVER_STATE.get("config", {}).get("db_path") or DEFAULT_DB_FILE
cookie = request.cookies.get(SESSION_COOKIE_NAME)
if cookie:
user = verify_session_token(cookie)
user = verify_session_token(cookie, db_path=db_path)
if user:
return user
auth_header = request.headers.get("Authorization")
if auth_header and auth_header.startswith("Bearer "):
token = auth_header.split(" ", 1)[1].strip()
user = verify_session_token(token)
user = verify_session_token(token, db_path=db_path)
if user:
return user
return None
@@ -602,9 +691,10 @@ def load_or_init_config(config_path: str = CONFIG_FILE_NAME) -> Dict[str, Any]:
config["db_path"] = resolve_writable_path(config["db_path"], "LOGAR-Server", DEFAULT_DB_FILE)
config["cert_dir"] = resolve_writable_path(config["cert_dir"], "LOGAR-Server", "certs")
global SESSION_SECRET
if not os.environ.get("LOGAR_SESSION_SECRET"):
global SESSION_SECRET, _CACHED_SESSION_SECRET
if not os.environ.get("LOGAR_SESSION_SECRET") and "session_secret" in config:
SESSION_SECRET = config["session_secret"]
_CACHED_SESSION_SECRET = config["session_secret"]
return config
# Fallback to writable data dir if CWD is read-only / Program Files
@@ -645,6 +735,7 @@ def load_or_init_config(config_path: str = CONFIG_FILE_NAME) -> Dict[str, Any]:
}
if not os.environ.get("LOGAR_SESSION_SECRET"):
SESSION_SECRET = session_sec
_CACHED_SESSION_SECRET = session_sec
os.makedirs(os.path.dirname(os.path.abspath(config_path)), exist_ok=True)
with open(config_path, "w", encoding="utf-8") as f:
@@ -894,6 +985,23 @@ def init_db(db_path: str, enrollment_secret: Optional[str] = None, max_seats: in
conn.execute("INSERT INTO hermes_tokens (token, name, created_at, status) VALUES (?, 'Default Hermes Agent', ?, 'active')",
(initial_token, now_iso))
# 7. Rate Limits & Server Meta Tables
conn.execute("""
CREATE TABLE IF NOT EXISTS rate_limits (
ip TEXT NOT NULL,
scope TEXT NOT NULL,
attempt_time REAL NOT NULL
)
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_rate_limits_ip_scope ON rate_limits (ip, scope, attempt_time)")
conn.execute("""
CREATE TABLE IF NOT EXISTS server_meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)
""")
status = "active" if max_seats > 0 else "unlicensed"
if enrollment_secret:
conn.execute("""
@@ -1508,7 +1616,8 @@ def login_page(request: Request):
async def process_login(request: Request):
"""Processes login credentials and issues session cookie with 2FA TOTP enforcement."""
client_ip = get_client_ip(request)
if not check_ip_rate_limit(client_ip, _LOGIN_FAILURES, max_attempts=10, window_seconds=60):
db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE)
if not check_ip_rate_limit(client_ip, _LOGIN_FAILURES, max_attempts=10, window_seconds=60, scope="login", db_path=db_path):
raise HTTPException(status_code=429, detail="Too many failed login attempts. Please try again later.")
username, password, totp_code = None, None, None
@@ -1530,9 +1639,8 @@ async def process_login(request: Request):
if not username or not password:
raise HTTPException(status_code=400, detail="Username and password are required")
db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE)
if not verify_user_credentials(username, password, db_path=db_path):
record_failed_ip_attempt(client_ip, _LOGIN_FAILURES)
record_failed_ip_attempt(client_ip, _LOGIN_FAILURES, scope="login", db_path=db_path)
await asyncio.sleep(0.5)
raise HTTPException(status_code=401, detail="Invalid username or password")
@@ -1546,12 +1654,12 @@ async def process_login(request: Request):
"message": "Two-factor authentication code required"
})
if not verify_totp_code(totp_info["secret"], totp_code):
record_failed_ip_attempt(client_ip, _LOGIN_FAILURES)
record_failed_ip_attempt(client_ip, _LOGIN_FAILURES, scope="login", db_path=db_path)
await asyncio.sleep(0.3)
raise HTTPException(status_code=401, detail="Invalid 6-digit 2FA verification code")
reset_failed_ip_attempts(client_ip, _LOGIN_FAILURES)
token = generate_session_token(username)
reset_failed_ip_attempts(client_ip, _LOGIN_FAILURES, scope="login", db_path=db_path)
token = generate_session_token(username, db_path=db_path)
is_https = request.url.scheme == "https" or request.headers.get("x-forwarded-proto") == "https"
response = JSONResponse(content={"status": "success", "username": username, "token": token})
response.set_cookie(
@@ -1642,10 +1750,10 @@ def enroll_client(req: ClientEnrollRequest, request: Request = None):
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):
db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE)
if not check_ip_rate_limit(client_ip, _ENROLL_FAILURES, max_attempts=15, window_seconds=60, scope="enroll", db_path=db_path):
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)
conn = sqlite3.connect(db_path)
c = conn.cursor()
@@ -1664,11 +1772,11 @@ def enroll_client(req: ClientEnrollRequest, request: Request = None):
if not secrets.compare_digest(str(req.enrollment_secret), str(expected_secret)):
conn.close()
record_failed_ip_attempt(client_ip, _ENROLL_FAILURES)
record_failed_ip_attempt(client_ip, _ENROLL_FAILURES, scope="enroll", db_path=db_path)
time.sleep(0.5)
raise HTTPException(status_code=403, detail="Invalid enrollment secret")
reset_failed_ip_attempts(client_ip, _ENROLL_FAILURES)
reset_failed_ip_attempts(client_ip, _ENROLL_FAILURES, scope="enroll", db_path=db_path)
ca_cert = SERVER_STATE.get("ca_cert")
ca_key = SERVER_STATE.get("ca_key")
+37
View File
@@ -510,6 +510,43 @@ class TestServerComponent(unittest.TestCase):
info_disabled = Server.get_user_totp_info("admin", db_path=self.test_db)
self.assertFalse(info_disabled["enabled"])
def test_persistent_session_secret_and_rate_limiting(self):
"""Tests DB persistence of session secret and sliding window rate limiting."""
Server.init_db(self.test_db)
# 1. Session secret persistence
sec1 = Server.get_session_secret(self.test_db)
self.assertTrue(len(sec1) >= 32)
conn = sqlite3.connect(self.test_db)
c = conn.cursor()
c.execute("SELECT value FROM server_meta WHERE key = 'session_secret'")
row = c.fetchone()
conn.close()
self.assertIsNotNone(row)
self.assertEqual(row[0], sec1)
# Token generated with test_db
token = Server.generate_session_token("admin", db_path=self.test_db)
self.assertEqual(Server.verify_session_token(token, db_path=self.test_db), "admin")
# 2. Rate limiting
test_ip = "10.0.0.99"
Server.reset_failed_ip_attempts(test_ip, scope="login", db_path=self.test_db)
self.assertTrue(Server.check_ip_rate_limit(test_ip, max_attempts=3, window_seconds=60, scope="login", db_path=self.test_db))
Server.record_failed_ip_attempt(test_ip, scope="login", db_path=self.test_db)
Server.record_failed_ip_attempt(test_ip, scope="login", db_path=self.test_db)
self.assertTrue(Server.check_ip_rate_limit(test_ip, max_attempts=3, window_seconds=60, scope="login", db_path=self.test_db))
Server.record_failed_ip_attempt(test_ip, scope="login", db_path=self.test_db)
self.assertFalse(Server.check_ip_rate_limit(test_ip, max_attempts=3, window_seconds=60, scope="login", db_path=self.test_db))
# Different scope ('enroll') should still be allowed
self.assertTrue(Server.check_ip_rate_limit(test_ip, max_attempts=3, window_seconds=60, scope="enroll", db_path=self.test_db))
Server.reset_failed_ip_attempts(test_ip, scope="login", db_path=self.test_db)
self.assertTrue(Server.check_ip_rate_limit(test_ip, max_attempts=3, window_seconds=60, scope="login", db_path=self.test_db))
if __name__ == "__main__":
unittest.main()