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
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 1m44s
This commit is contained in:
+1
-1
Submodule LOGAR-LICENSING updated: 5a8d69c005...1252941d93
+133
-25
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user