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
+
+
+
H
+
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