mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-22 18:30:28 +00:00
404e24ac9d
PR #1957 deleted the SESSION_TTL = 86400 * 30 module-level constant in favor of the new _resolve_session_ttl() helper. Two existing regression tests pin the constant: test_auth_sessions.TestSessionPruning.test_session_ttl_is_24_hours imports SESSION_TTL directly, and test_v050258_opus_followups.test_redirect_session_ttl_30_days asserts the literal "SESSION_TTL = 86400 * 30" line is present in source (guarding against the daily-kick-out regression from #1419). Restore SESSION_TTL as the named fallback for _resolve_session_ttl(); the new env-var/settings.json path is unchanged. Backwards-compatible. Also fix the new TestSessionTtlResolution suite: - Switch from pytest's `monkeypatch` fixture (incompatible with unittest.TestCase subclasses) to setUp/tearDown env snapshotting - Reconcile clamp tests with actual implementation: out-of-range env values fall through to settings/default, not snap to bounds - test_session_uses_dynamic_ttl now sets the env var so the dynamic resolved value (3600s) is exercised rather than expecting the default Verified: tests/test_auth_sessions.py + tests/test_v050258_opus_followups.py 21/21 pass.
367 lines
14 KiB
Python
367 lines
14 KiB
Python
"""
|
|
Hermes Web UI -- Optional password authentication.
|
|
Off by default. Enable by setting HERMES_WEBUI_PASSWORD env var
|
|
or configuring a password in the Settings panel.
|
|
"""
|
|
import hashlib
|
|
import hmac
|
|
import http.cookies
|
|
import json
|
|
import logging
|
|
import os
|
|
import secrets
|
|
import tempfile
|
|
import time
|
|
|
|
from api.config import STATE_DIR, load_settings
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# Default session TTL — 30 days. Kept as a module-level constant for backwards
|
|
# compatibility with downstream code and regression tests that import it.
|
|
# At runtime, prefer ``_resolve_session_ttl()`` which honours the env var and
|
|
# settings.json overrides; this constant is the floor / fallback.
|
|
SESSION_TTL = 86400 * 30 # 30 days
|
|
|
|
|
|
def _resolve_session_ttl() -> int:
|
|
"""Resolve session TTL from env > settings > default.
|
|
|
|
Priority mirrors get_password_hash(): HERMES_WEBUI_SESSION_TTL env var
|
|
first, then settings.json, falling back to ``SESSION_TTL`` (30 days).
|
|
Clamped to [60s, 1 year] to prevent runaway cookies or self-lockout.
|
|
"""
|
|
env_v = os.getenv('HERMES_WEBUI_SESSION_TTL', '').strip()
|
|
if env_v.isdigit():
|
|
val = int(env_v)
|
|
if 60 <= val <= 86400 * 365:
|
|
return val
|
|
s = load_settings()
|
|
v = s.get('session_ttl_seconds')
|
|
if isinstance(v, int) and 60 <= v <= 86400 * 365:
|
|
return v
|
|
return SESSION_TTL
|
|
|
|
|
|
# ── Public paths (no auth required) ─────────────────────────────────────────
|
|
PUBLIC_PATHS = frozenset({
|
|
'/login', '/health', '/favicon.ico', '/sw.js',
|
|
'/api/auth/login', '/api/auth/status',
|
|
'/manifest.json', '/manifest.webmanifest',
|
|
})
|
|
|
|
COOKIE_NAME = 'hermes_session'
|
|
|
|
_SESSIONS_FILE = STATE_DIR / '.sessions.json'
|
|
|
|
|
|
def _load_sessions() -> dict[str, float]:
|
|
"""Load persisted sessions from STATE_DIR, pruning expired entries.
|
|
|
|
Returns an empty dict on any read or parse error so startup is never
|
|
blocked by a corrupt or missing sessions file.
|
|
"""
|
|
try:
|
|
if _SESSIONS_FILE.exists():
|
|
data = json.loads(_SESSIONS_FILE.read_text(encoding='utf-8'))
|
|
if not isinstance(data, dict):
|
|
raise ValueError('malformed sessions file — expected dict')
|
|
now = time.time()
|
|
return {t: exp for t, exp in data.items()
|
|
if isinstance(t, str) and isinstance(exp, (int, float)) and exp > now}
|
|
except Exception as e:
|
|
logger.debug("Failed to load sessions file, starting fresh: %s", e)
|
|
return {}
|
|
|
|
|
|
def _save_sessions(sessions: dict[str, float]) -> None:
|
|
"""Atomically persist sessions to STATE_DIR/.sessions.json (0600).
|
|
|
|
Uses a temp file + os.replace() so a crash mid-write never leaves a
|
|
truncated file. Mirrors the same pattern as .signing_key persistence.
|
|
"""
|
|
try:
|
|
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
|
fd, tmp = tempfile.mkstemp(dir=STATE_DIR, suffix='.sessions.tmp')
|
|
try:
|
|
with os.fdopen(fd, 'w', encoding='utf-8') as f:
|
|
json.dump(sessions, f)
|
|
os.chmod(tmp, 0o600)
|
|
os.replace(tmp, _SESSIONS_FILE)
|
|
except Exception:
|
|
try:
|
|
os.unlink(tmp)
|
|
except OSError:
|
|
pass
|
|
raise
|
|
except Exception as e:
|
|
logger.debug("Failed to persist sessions: %s", e)
|
|
|
|
|
|
# Active sessions: token -> expiry timestamp (persisted across restarts via STATE_DIR)
|
|
_sessions = _load_sessions()
|
|
|
|
# ── Login rate limiter ──────────────────────────────────────────────────────
|
|
_LOGIN_ATTEMPTS_FILE = STATE_DIR / '.login_attempts.json'
|
|
_LOGIN_MAX_ATTEMPTS = 5
|
|
_LOGIN_WINDOW = 60 # seconds
|
|
|
|
|
|
def _load_login_attempts() -> dict[str, list[float]]:
|
|
"""Load persisted login attempts from STATE_DIR, pruning expired entries."""
|
|
try:
|
|
if _LOGIN_ATTEMPTS_FILE.exists():
|
|
data = json.loads(_LOGIN_ATTEMPTS_FILE.read_text(encoding='utf-8'))
|
|
if not isinstance(data, dict):
|
|
raise ValueError('malformed login-attempts file — expected dict')
|
|
now = time.time()
|
|
attempts: dict[str, list[float]] = {}
|
|
for ip, raw_times in data.items():
|
|
if not isinstance(ip, str) or not isinstance(raw_times, list):
|
|
continue
|
|
fresh = [
|
|
float(t)
|
|
for t in raw_times
|
|
if isinstance(t, (int, float)) and now - float(t) < _LOGIN_WINDOW
|
|
]
|
|
if fresh:
|
|
attempts[ip] = fresh
|
|
return attempts
|
|
except Exception as e:
|
|
logger.debug("Failed to load login attempts file, starting fresh: %s", e)
|
|
return {}
|
|
|
|
|
|
def _save_login_attempts(attempts: dict[str, list[float]]) -> None:
|
|
"""Atomically persist login attempts to STATE_DIR/.login_attempts.json (0600)."""
|
|
try:
|
|
_LOGIN_ATTEMPTS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
fd, tmp = tempfile.mkstemp(dir=_LOGIN_ATTEMPTS_FILE.parent, suffix='.login_attempts.tmp')
|
|
try:
|
|
with os.fdopen(fd, 'w', encoding='utf-8') as f:
|
|
json.dump(attempts, f)
|
|
os.chmod(tmp, 0o600)
|
|
os.replace(tmp, _LOGIN_ATTEMPTS_FILE)
|
|
except Exception:
|
|
try:
|
|
os.unlink(tmp)
|
|
except OSError:
|
|
pass
|
|
raise
|
|
except Exception as e:
|
|
logger.debug("Failed to persist login attempts: %s", e)
|
|
|
|
|
|
_login_attempts = _load_login_attempts() # ip -> [timestamp, ...]
|
|
|
|
|
|
def _check_login_rate(ip: str) -> bool:
|
|
"""Return True if the IP is allowed to attempt login."""
|
|
now = time.time()
|
|
attempts = _login_attempts.get(ip, [])
|
|
# Prune old attempts
|
|
attempts = [t for t in attempts if now - t < _LOGIN_WINDOW]
|
|
if attempts:
|
|
_login_attempts[ip] = attempts
|
|
else:
|
|
_login_attempts.pop(ip, None)
|
|
_save_login_attempts(_login_attempts)
|
|
return len(attempts) < _LOGIN_MAX_ATTEMPTS
|
|
|
|
|
|
def _record_login_attempt(ip: str) -> None:
|
|
now = time.time()
|
|
attempts = _login_attempts.get(ip, [])
|
|
attempts.append(now)
|
|
_login_attempts[ip] = attempts
|
|
_save_login_attempts(_login_attempts)
|
|
|
|
|
|
def _signing_key():
|
|
"""Return a random signing key, generating and persisting one on first call."""
|
|
key_file = STATE_DIR / '.signing_key'
|
|
try:
|
|
if key_file.exists():
|
|
raw = key_file.read_bytes()
|
|
if len(raw) >= 32:
|
|
return raw[:32]
|
|
except Exception:
|
|
logger.debug("Failed to read or access signing key file, using in-memory key")
|
|
# Generate a new random key
|
|
key = secrets.token_bytes(32)
|
|
try:
|
|
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
|
key_file.write_bytes(key)
|
|
key_file.chmod(0o600)
|
|
except Exception:
|
|
logger.debug("Failed to persist signing key, using in-memory key only")
|
|
return key
|
|
|
|
|
|
def _hash_password(password):
|
|
"""PBKDF2-SHA256 with 600k iterations (OWASP recommendation).
|
|
Salt is the persisted random signing key, which is secret and unique per
|
|
installation. This keeps the stored hash format a plain hex string
|
|
(no format change to settings.json) while replacing the predictable
|
|
STATE_DIR-derived salt from the original implementation."""
|
|
salt = _signing_key()
|
|
dk = hashlib.pbkdf2_hmac('sha256', password.encode(), salt, 600_000)
|
|
return dk.hex()
|
|
|
|
|
|
def get_password_hash() -> str | None:
|
|
"""Return the active password hash, or None if auth is disabled.
|
|
Priority: env var > settings.json."""
|
|
env_pw = os.getenv('HERMES_WEBUI_PASSWORD', '').strip()
|
|
if env_pw:
|
|
return _hash_password(env_pw)
|
|
settings = load_settings()
|
|
return settings.get('password_hash') or None
|
|
|
|
|
|
def is_auth_enabled() -> bool:
|
|
"""True if a password is configured (env var or settings)."""
|
|
return get_password_hash() is not None
|
|
|
|
|
|
def verify_password(plain) -> bool:
|
|
"""Verify a plaintext password against the stored hash."""
|
|
expected = get_password_hash()
|
|
if not expected:
|
|
return False
|
|
return hmac.compare_digest(_hash_password(plain), expected)
|
|
|
|
|
|
def create_session() -> str:
|
|
"""Create a new auth session. Returns signed cookie value."""
|
|
token = secrets.token_hex(32)
|
|
_sessions[token] = time.time() + _resolve_session_ttl()
|
|
_save_sessions(_sessions)
|
|
sig = hmac.new(_signing_key(), token.encode(), hashlib.sha256).hexdigest()[:32]
|
|
return f"{token}.{sig}"
|
|
|
|
|
|
def _prune_expired_sessions():
|
|
"""Remove all expired session entries to prevent unbounded memory growth."""
|
|
now = time.time()
|
|
expired = [t for t, exp in _sessions.items() if now > exp]
|
|
if expired:
|
|
for token in expired:
|
|
_sessions.pop(token, None)
|
|
_save_sessions(_sessions)
|
|
|
|
|
|
def verify_session(cookie_value) -> bool:
|
|
"""Verify a signed session cookie. Returns True if valid and not expired."""
|
|
if not cookie_value or '.' not in cookie_value:
|
|
return False
|
|
_prune_expired_sessions() # lazy cleanup on every verification attempt
|
|
token, sig = cookie_value.rsplit('.', 1)
|
|
expected_sig = hmac.new(_signing_key(), token.encode(), hashlib.sha256).hexdigest()[:32]
|
|
if not hmac.compare_digest(sig, expected_sig):
|
|
return False
|
|
expiry = _sessions.get(token)
|
|
if not expiry or time.time() > expiry:
|
|
_sessions.pop(token, None)
|
|
return False
|
|
return True
|
|
|
|
|
|
def invalidate_session(cookie_value) -> None:
|
|
"""Remove a session token."""
|
|
if cookie_value and '.' in cookie_value:
|
|
token = cookie_value.rsplit('.', 1)[0]
|
|
if token in _sessions:
|
|
_sessions.pop(token, None)
|
|
_save_sessions(_sessions)
|
|
|
|
|
|
def parse_cookie(handler) -> str | None:
|
|
"""Extract the auth cookie from the request headers."""
|
|
cookie_header = handler.headers.get('Cookie', '')
|
|
if not cookie_header:
|
|
return None
|
|
cookie = http.cookies.SimpleCookie()
|
|
try:
|
|
cookie.load(cookie_header)
|
|
except http.cookies.CookieError:
|
|
return None
|
|
morsel = cookie.get(COOKIE_NAME)
|
|
return morsel.value if morsel else None
|
|
|
|
|
|
def check_auth(handler, parsed) -> bool:
|
|
"""Check if request is authorized. Returns True if OK.
|
|
If not authorized, sends 401 (API) or 302 redirect (page) and returns False."""
|
|
if not is_auth_enabled():
|
|
return True
|
|
# Public paths don't require auth
|
|
if parsed.path in PUBLIC_PATHS or parsed.path.startswith('/static/') or parsed.path.startswith('/session/static/'):
|
|
return True
|
|
# Check session cookie
|
|
cookie_val = parse_cookie(handler)
|
|
if cookie_val and verify_session(cookie_val):
|
|
return True
|
|
# Not authorized
|
|
if parsed.path.startswith('/api/'):
|
|
handler.send_response(401)
|
|
handler.send_header('Content-Type', 'application/json')
|
|
handler.end_headers()
|
|
handler.wfile.write(b'{"error":"Authentication required"}')
|
|
else:
|
|
handler.send_response(302)
|
|
# Pass the original path as ?next= so login.js redirects back after auth.
|
|
# SECURITY/CORRECTNESS: the inner `?` and `&` MUST be percent-encoded
|
|
# when stuffed into the outer `?next=` parameter, otherwise:
|
|
# (a) multi-param query strings get truncated at the first inner `&`
|
|
# (e.g. `/api/sessions?limit=50&offset=0` would round-trip as
|
|
# just `/api/sessions?limit=50` after the browser parses the
|
|
# outer URL — `offset=0` becomes a separate top-level query
|
|
# parameter that the login page ignores).
|
|
# (b) attacker-controlled paths could inject a second `next=`
|
|
# parameter; per RFC 3986 the duplicate behaviour is undefined
|
|
# and parsers diverge (Python's parse_qs returns last-match,
|
|
# URLSearchParams returns first-match), opening a query-pollution
|
|
# footgun even though _safeNextPath() rejects most malicious
|
|
# shapes downstream.
|
|
# Encoding the entire `path?query` blob with quote(safe='/') turns
|
|
# `?` → `%3F` and `&` → `%26`, so the outer parameter holds exactly
|
|
# one path-with-query string and `searchParams.get('next')` returns
|
|
# the full original URL (the browser auto-decodes once).
|
|
# (Opus pre-release advisor finding for v0.50.258.)
|
|
import urllib.parse as _urlparse
|
|
_path_with_query = parsed.path or '/'
|
|
if parsed.query:
|
|
_path_with_query += '?' + parsed.query
|
|
# safe='/' keeps path separators readable; everything else (including
|
|
# `?`, `&`, `=`) gets percent-encoded.
|
|
_next = _urlparse.quote(_path_with_query, safe='/')
|
|
handler.send_header('Location', 'login?next=' + _next)
|
|
handler.end_headers()
|
|
return False
|
|
|
|
|
|
def set_auth_cookie(handler, cookie_value) -> None:
|
|
"""Set the auth cookie on the response."""
|
|
cookie = http.cookies.SimpleCookie()
|
|
cookie[COOKIE_NAME] = cookie_value
|
|
cookie[COOKIE_NAME]['httponly'] = True
|
|
cookie[COOKIE_NAME]['samesite'] = 'Lax'
|
|
cookie[COOKIE_NAME]['path'] = '/'
|
|
cookie[COOKIE_NAME]['max-age'] = str(_resolve_session_ttl())
|
|
# Set Secure flag when connection is HTTPS
|
|
if getattr(handler.request, 'getpeercert', None) is not None or handler.headers.get('X-Forwarded-Proto', '') == 'https':
|
|
cookie[COOKIE_NAME]['secure'] = True
|
|
handler.send_header('Set-Cookie', cookie[COOKIE_NAME].OutputString())
|
|
|
|
|
|
def clear_auth_cookie(handler) -> None:
|
|
"""Clear the auth cookie on the response."""
|
|
cookie = http.cookies.SimpleCookie()
|
|
cookie[COOKIE_NAME] = ''
|
|
cookie[COOKIE_NAME]['httponly'] = True
|
|
cookie[COOKIE_NAME]['path'] = '/'
|
|
cookie[COOKIE_NAME]['max-age'] = '0'
|
|
handler.send_header('Set-Cookie', cookie[COOKIE_NAME].OutputString())
|