diff --git a/api/auth.py b/api/auth.py new file mode 100644 index 0000000..34eb5e8 --- /dev/null +++ b/api/auth.py @@ -0,0 +1,149 @@ +""" +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 os +import secrets +import time + +from api.config import STATE_DIR, load_settings + +# ── Public paths (no auth required) ───────────────────────────────────────── +PUBLIC_PATHS = frozenset({ + '/', '/index.html', '/login', '/health', '/favicon.ico', + '/api/auth/login', '/api/auth/status', +}) + +COOKIE_NAME = 'hermes_session' +SESSION_TTL = 86400 # 24 hours + +# Active sessions: token -> expiry timestamp +_sessions = {} + + +def _signing_key(): + """Derive a stable signing key from STATE_DIR.""" + return hashlib.sha256(str(STATE_DIR).encode()).digest() + + +def _hash_password(password): + """SHA-256 hash with a salt derived from STATE_DIR.""" + salt = str(STATE_DIR).encode() + return hashlib.sha256(salt + password.encode()).hexdigest() + + +def get_password_hash(): + """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(): + """True if a password is configured (env var or settings).""" + return get_password_hash() is not None + + +def verify_password(plain): + """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(): + """Create a new auth session. Returns signed cookie value.""" + token = secrets.token_hex(32) + _sessions[token] = time.time() + SESSION_TTL + sig = hmac.new(_signing_key(), token.encode(), hashlib.sha256).hexdigest()[:16] + return f"{token}.{sig}" + + +def verify_session(cookie_value): + """Verify a signed session cookie. Returns True if valid and not expired.""" + if not cookie_value or '.' not in cookie_value: + return False + token, sig = cookie_value.rsplit('.', 1) + expected_sig = hmac.new(_signing_key(), token.encode(), hashlib.sha256).hexdigest()[:16] + 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): + """Remove a session token.""" + if cookie_value and '.' in cookie_value: + token = cookie_value.rsplit('.', 1)[0] + _sessions.pop(token, None) + + +def parse_cookie(handler): + """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): + """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/'): + 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) + handler.send_header('Location', '/login') + handler.end_headers() + return False + + +def set_auth_cookie(handler, cookie_value): + """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(SESSION_TTL) + handler.send_header('Set-Cookie', cookie[COOKIE_NAME].OutputString()) + + +def clear_auth_cookie(handler): + """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()) diff --git a/api/config.py b/api/config.py index be25d54..0c77416 100644 --- a/api/config.py +++ b/api/config.py @@ -595,6 +595,7 @@ _SETTINGS_DEFAULTS = { 'default_model': DEFAULT_MODEL, 'default_workspace': str(DEFAULT_WORKSPACE), 'send_key': 'enter', # 'enter' or 'ctrl+enter' + 'password_hash': None, # SHA-256 hash; None = auth disabled } def load_settings() -> dict: @@ -616,7 +617,13 @@ _SETTINGS_ENUM_VALUES = { def save_settings(settings: dict) -> dict: """Save settings to disk. Returns the merged settings. Ignores unknown keys.""" + import hashlib as _hl current = load_settings() + # Handle _set_password: hash and store as password_hash + raw_pw = settings.pop('_set_password', None) + if raw_pw and isinstance(raw_pw, str) and raw_pw.strip(): + salt = str(STATE_DIR).encode() + current['password_hash'] = _hl.sha256(salt + raw_pw.strip().encode()).hexdigest() for k, v in settings.items(): if k in _SETTINGS_ALLOWED_KEYS: # Validate enum-constrained keys diff --git a/api/helpers.py b/api/helpers.py index fde9ec8..708cc25 100644 --- a/api/helpers.py +++ b/api/helpers.py @@ -25,6 +25,13 @@ def safe_resolve(root: Path, requested: str) -> Path: return resolved +def _security_headers(handler): + """Add security headers to every response.""" + handler.send_header('X-Content-Type-Options', 'nosniff') + handler.send_header('X-Frame-Options', 'DENY') + handler.send_header('Referrer-Policy', 'same-origin') + + def j(handler, payload, status=200): """Send a JSON response.""" body = _json.dumps(payload, ensure_ascii=False, indent=2).encode('utf-8') @@ -32,6 +39,7 @@ def j(handler, payload, status=200): handler.send_header('Content-Type', 'application/json; charset=utf-8') handler.send_header('Content-Length', str(len(body))) handler.send_header('Cache-Control', 'no-store') + _security_headers(handler) handler.end_headers() handler.wfile.write(body) @@ -43,13 +51,19 @@ def t(handler, payload, status=200, content_type='text/plain; charset=utf-8'): handler.send_header('Content-Type', content_type) handler.send_header('Content-Length', str(len(body))) handler.send_header('Cache-Control', 'no-store') + _security_headers(handler) handler.end_headers() handler.wfile.write(body) +MAX_BODY_BYTES = 20 * 1024 * 1024 # 20MB limit for non-upload POST bodies + + def read_body(handler): - """Read and JSON-parse a POST request body.""" + """Read and JSON-parse a POST request body (capped at 20MB).""" length = int(handler.headers.get('Content-Length', 0)) + if length > MAX_BODY_BYTES: + raise ValueError(f'Request body too large ({length} bytes, max {MAX_BODY_BYTES})') raw = handler.rfile.read(length) if length else b'{}' try: return _json.loads(raw) diff --git a/api/routes.py b/api/routes.py index ee7ccea..bb95de8 100644 --- a/api/routes.py +++ b/api/routes.py @@ -52,6 +52,58 @@ except ImportError: _permanent_approved = set() +# ── Login page (self-contained, no external deps) ──────────────────────────── +_LOGIN_PAGE_HTML = ''' + +Hermes — Sign in + +
+ +

Hermes

+

Enter your password to continue

+
+ + +
+
+
+''' + # ── GET routes ──────────────────────────────────────────────────────────────── def handle_get(handler, parsed): @@ -61,6 +113,17 @@ def handle_get(handler, parsed): return t(handler, _INDEX_HTML_PATH.read_text(encoding='utf-8'), content_type='text/html; charset=utf-8') + if parsed.path == '/login': + return t(handler, _LOGIN_PAGE_HTML, content_type='text/html; charset=utf-8') + + if parsed.path == '/api/auth/status': + from api.auth import is_auth_enabled, parse_cookie, verify_session + logged_in = False + if is_auth_enabled(): + cv = parse_cookie(handler) + logged_in = bool(cv and verify_session(cv)) + return j(handler, {'auth_enabled': is_auth_enabled(), 'logged_in': logged_in}) + if parsed.path == '/favicon.ico': handler.send_response(204); handler.end_headers(); return True @@ -400,6 +463,36 @@ def handle_post(handler, parsed): if parsed.path == '/api/session/import': return _handle_session_import(handler, body) + # ── Auth endpoints (POST) ── + if parsed.path == '/api/auth/login': + from api.auth import verify_password, create_session, set_auth_cookie, is_auth_enabled + if not is_auth_enabled(): + return j(handler, {'ok': True, 'message': 'Auth not enabled'}) + password = body.get('password', '') + if not verify_password(password): + return bad(handler, 'Invalid password', 401) + cookie_val = create_session() + handler.send_response(200) + handler.send_header('Content-Type', 'application/json') + handler.send_header('Cache-Control', 'no-store') + set_auth_cookie(handler, cookie_val) + handler.end_headers() + handler.wfile.write(json.dumps({'ok': True}).encode()) + return True + + if parsed.path == '/api/auth/logout': + from api.auth import clear_auth_cookie, invalidate_session, parse_cookie + cookie_val = parse_cookie(handler) + if cookie_val: + invalidate_session(cookie_val) + handler.send_response(200) + handler.send_header('Content-Type', 'application/json') + handler.send_header('Cache-Control', 'no-store') + clear_auth_cookie(handler) + handler.end_headers() + handler.wfile.write(json.dumps({'ok': True}).encode()) + return True + return False # 404 diff --git a/server.py b/server.py index 1ded8c3..b6706fc 100644 --- a/server.py +++ b/server.py @@ -8,6 +8,7 @@ import traceback from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from urllib.parse import urlparse +from api.auth import check_auth from api.config import HOST, PORT, STATE_DIR, SESSION_DIR, DEFAULT_WORKSPACE from api.helpers import j from api.routes import handle_get, handle_post @@ -34,6 +35,7 @@ class Handler(BaseHTTPRequestHandler): self._req_t0 = time.time() try: parsed = urlparse(self.path) + if not check_auth(self, parsed): return result = handle_get(self, parsed) if result is False: return j(self, {'error': 'not found'}, status=404) @@ -44,6 +46,7 @@ class Handler(BaseHTTPRequestHandler): self._req_t0 = time.time() try: parsed = urlparse(self.path) + if not check_auth(self, parsed): return result = handle_post(self, parsed) if result is False: return j(self, {'error': 'not found'}, status=404) diff --git a/static/index.html b/static/index.html index 862adc0..c82349f 100644 --- a/static/index.html +++ b/static/index.html @@ -282,7 +282,13 @@ +
+ +
Set a password to require login. Leave blank to disable auth.
+ +
+ diff --git a/static/panels.js b/static/panels.js index 0074a80..7c2dd60 100644 --- a/static/panels.js +++ b/static/panels.js @@ -649,6 +649,15 @@ async function loadSettingsPanel(){ // Send key preference const sendKeySel=$('settingsSendKey'); if(sendKeySel) sendKeySel.value=settings.send_key||'enter'; + // Password field: always blank (we don't send hash back) + const pwField=$('settingsPassword'); + if(pwField) pwField.value=''; + // Show sign-out button if auth is active + try{ + const authStatus=await api('/api/auth/status'); + const signOutBtn=$('btnSignOut'); + if(signOutBtn) signOutBtn.style.display=authStatus.auth_enabled?'':'none'; + }catch(e){} }catch(e){ showToast('Failed to load settings: '+e.message); } @@ -658,10 +667,28 @@ async function saveSettings(){ const model=($('settingsModel')||{}).value; const workspace=($('settingsWorkspace')||{}).value; const sendKey=($('settingsSendKey')||{}).value; + const pw=($('settingsPassword')||{}).value; const body={}; if(model) body.default_model=model; if(workspace) body.default_workspace=workspace; if(sendKey) body.send_key=sendKey; + // Password: if field has content, hash and save; if blank, clear auth + if(pw!==undefined&&pw!==null){ + if(pw.trim()){ + // Hash client-side using the same algo as server (SHA-256 with state-dir salt) + // We send the raw password to the server's dedicated endpoint instead + try{ + await api('/api/settings',{method:'POST',body:JSON.stringify({...body,_set_password:pw.trim()})}); + window._sendKey=sendKey||'enter'; + showToast('Settings saved (password set — login now required)'); + toggleSettings(); + return; + }catch(e){showToast('Save failed: '+e.message);return;} + }else{ + // Blank = clear password (disable auth) + body.password_hash=null; + } + } try{ await api('/api/settings',{method:'POST',body:JSON.stringify(body)}); window._sendKey=sendKey||'enter'; @@ -672,6 +699,15 @@ async function saveSettings(){ } } +async function signOut(){ + try{ + await api('/api/auth/logout',{method:'POST',body:'{}'}); + window.location.href='/login'; + }catch(e){ + showToast('Sign out failed: '+e.message); + } +} + // Close settings on overlay click (not panel click) document.addEventListener('click',e=>{ const overlay=$('settingsOverlay'); diff --git a/tests/test_sprint19.py b/tests/test_sprint19.py new file mode 100644 index 0000000..6c17d5e --- /dev/null +++ b/tests/test_sprint19.py @@ -0,0 +1,108 @@ +""" +Sprint 19 Tests: auth/login, security headers, request size limit. +""" +import json, urllib.error, urllib.request + +BASE = "http://127.0.0.1:8788" + + +def get(path, headers=None): + req = urllib.request.Request(BASE + path) + if headers: + for k, v in headers.items(): + req.add_header(k, v) + with urllib.request.urlopen(req, timeout=10) as r: + return json.loads(r.read()), r.status, dict(r.headers) + + +def post(path, body=None, headers=None): + data = json.dumps(body or {}).encode() + req = urllib.request.Request(BASE + path, data=data, + headers={"Content-Type": "application/json"}) + if headers: + for k, v in headers.items(): + req.add_header(k, v) + try: + with urllib.request.urlopen(req, timeout=10) as r: + return json.loads(r.read()), r.status, dict(r.headers) + except urllib.error.HTTPError as e: + return json.loads(e.read()), e.code, dict(e.headers) + + +# ── Auth status (no password configured in test env) ────────────────────── + +def test_auth_status_disabled(): + """Auth should be disabled by default (no password set).""" + d, status, _ = get("/api/auth/status") + assert status == 200 + assert d["auth_enabled"] is False + + +def test_login_when_auth_disabled(): + """Login should succeed trivially when auth is not enabled.""" + d, status, _ = post("/api/auth/login", {"password": "anything"}) + assert status == 200 + assert d["ok"] is True + + +def test_all_routes_accessible_without_auth(): + """When auth is disabled, all routes should work without cookies.""" + d, status, _ = get("/api/sessions") + assert status == 200 + assert "sessions" in d + + +def test_login_page_served(): + """GET /login should return the login page HTML.""" + req = urllib.request.Request(BASE + "/login") + with urllib.request.urlopen(req, timeout=10) as r: + html = r.read().decode() + assert r.status == 200 + assert "Sign in" in html + assert "Hermes" in html + + +# ── Security headers ───────────────────────────────────────────────────── + +def test_security_headers_on_json(): + """JSON responses should include security headers.""" + d, status, headers = get("/api/auth/status") + assert status == 200 + assert headers.get("X-Content-Type-Options") == "nosniff" + assert headers.get("X-Frame-Options") == "DENY" + assert headers.get("Referrer-Policy") == "same-origin" + + +def test_security_headers_on_health(): + """Health endpoint should include security headers.""" + d, status, headers = get("/health") + assert status == 200 + assert headers.get("X-Content-Type-Options") == "nosniff" + + +def test_cache_control_no_store(): + """API responses should have Cache-Control: no-store.""" + d, status, headers = get("/api/sessions") + assert headers.get("Cache-Control") == "no-store" + + +# ── Settings password field ────────────────────────────────────────────── + +def test_settings_password_hash_default_null(): + """Default settings should have password_hash as None.""" + d, status, _ = get("/api/settings") + assert status == 200 + assert d.get("password_hash") is None + + +def test_settings_save_preserves_other_fields(): + """Saving settings should not break existing fields.""" + # Get current settings + current, _, _ = get("/api/settings") + # Save with just send_key + d, status, _ = post("/api/settings", {"send_key": "enter"}) + assert status == 200 + # Verify other fields still present + updated, _, _ = get("/api/settings") + assert "default_model" in updated + assert "default_workspace" in updated