feat: Sprint 19 — password auth, security headers, login page
Auth system (off by default, zero friction for localhost): - New api/auth.py module: password hashing (SHA-256 + STATE_DIR salt), signed HMAC session cookies (24h TTL), auth middleware - Enable via HERMES_WEBUI_PASSWORD env var or Settings panel - Minimal dark-themed login page at /login (self-contained HTML) - POST /api/auth/login, /api/auth/logout, GET /api/auth/status - Settings panel: "Access Password" field + "Sign Out" button - password_hash added to settings.json (null = auth disabled) Security hardening: - Security headers on all responses: X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Referrer-Policy: same-origin - POST body size limit: 20MB cap in read_body() to prevent DoS Closes #23. 9 new tests. Total: 304 passed, 0 regressions. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
149
api/auth.py
Normal file
149
api/auth.py
Normal file
@@ -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())
|
||||||
@@ -595,6 +595,7 @@ _SETTINGS_DEFAULTS = {
|
|||||||
'default_model': DEFAULT_MODEL,
|
'default_model': DEFAULT_MODEL,
|
||||||
'default_workspace': str(DEFAULT_WORKSPACE),
|
'default_workspace': str(DEFAULT_WORKSPACE),
|
||||||
'send_key': 'enter', # 'enter' or 'ctrl+enter'
|
'send_key': 'enter', # 'enter' or 'ctrl+enter'
|
||||||
|
'password_hash': None, # SHA-256 hash; None = auth disabled
|
||||||
}
|
}
|
||||||
|
|
||||||
def load_settings() -> dict:
|
def load_settings() -> dict:
|
||||||
@@ -616,7 +617,13 @@ _SETTINGS_ENUM_VALUES = {
|
|||||||
|
|
||||||
def save_settings(settings: dict) -> dict:
|
def save_settings(settings: dict) -> dict:
|
||||||
"""Save settings to disk. Returns the merged settings. Ignores unknown keys."""
|
"""Save settings to disk. Returns the merged settings. Ignores unknown keys."""
|
||||||
|
import hashlib as _hl
|
||||||
current = load_settings()
|
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():
|
for k, v in settings.items():
|
||||||
if k in _SETTINGS_ALLOWED_KEYS:
|
if k in _SETTINGS_ALLOWED_KEYS:
|
||||||
# Validate enum-constrained keys
|
# Validate enum-constrained keys
|
||||||
|
|||||||
@@ -25,6 +25,13 @@ def safe_resolve(root: Path, requested: str) -> Path:
|
|||||||
return resolved
|
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):
|
def j(handler, payload, status=200):
|
||||||
"""Send a JSON response."""
|
"""Send a JSON response."""
|
||||||
body = _json.dumps(payload, ensure_ascii=False, indent=2).encode('utf-8')
|
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-Type', 'application/json; charset=utf-8')
|
||||||
handler.send_header('Content-Length', str(len(body)))
|
handler.send_header('Content-Length', str(len(body)))
|
||||||
handler.send_header('Cache-Control', 'no-store')
|
handler.send_header('Cache-Control', 'no-store')
|
||||||
|
_security_headers(handler)
|
||||||
handler.end_headers()
|
handler.end_headers()
|
||||||
handler.wfile.write(body)
|
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-Type', content_type)
|
||||||
handler.send_header('Content-Length', str(len(body)))
|
handler.send_header('Content-Length', str(len(body)))
|
||||||
handler.send_header('Cache-Control', 'no-store')
|
handler.send_header('Cache-Control', 'no-store')
|
||||||
|
_security_headers(handler)
|
||||||
handler.end_headers()
|
handler.end_headers()
|
||||||
handler.wfile.write(body)
|
handler.wfile.write(body)
|
||||||
|
|
||||||
|
|
||||||
|
MAX_BODY_BYTES = 20 * 1024 * 1024 # 20MB limit for non-upload POST bodies
|
||||||
|
|
||||||
|
|
||||||
def read_body(handler):
|
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))
|
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'{}'
|
raw = handler.rfile.read(length) if length else b'{}'
|
||||||
try:
|
try:
|
||||||
return _json.loads(raw)
|
return _json.loads(raw)
|
||||||
|
|||||||
@@ -52,6 +52,58 @@ except ImportError:
|
|||||||
_permanent_approved = set()
|
_permanent_approved = set()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Login page (self-contained, no external deps) ────────────────────────────
|
||||||
|
_LOGIN_PAGE_HTML = '''<!doctype html>
|
||||||
|
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>Hermes — Sign in</title>
|
||||||
|
<style>
|
||||||
|
*{box-sizing:border-box;margin:0;padding:0}
|
||||||
|
body{background:#1a1a2e;color:#e8e8f0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;
|
||||||
|
height:100vh;display:flex;align-items:center;justify-content:center}
|
||||||
|
.card{background:#16213e;border:1px solid rgba(255,255,255,.08);border-radius:16px;padding:36px 32px;
|
||||||
|
width:320px;text-align:center;box-shadow:0 8px 32px rgba(0,0,0,.3)}
|
||||||
|
.logo{width:48px;height:48px;border-radius:12px;background:linear-gradient(145deg,#e8a030,#e94560);
|
||||||
|
display:flex;align-items:center;justify-content:center;font-weight:800;font-size:20px;color:#fff;
|
||||||
|
margin:0 auto 12px;box-shadow:0 2px 12px rgba(233,69,96,.3)}
|
||||||
|
h1{font-size:18px;font-weight:600;margin-bottom:4px}
|
||||||
|
.sub{font-size:12px;color:#8888aa;margin-bottom:24px}
|
||||||
|
input{width:100%;padding:10px 14px;border-radius:10px;border:1px solid rgba(255,255,255,.1);
|
||||||
|
background:rgba(255,255,255,.04);color:#e8e8f0;font-size:14px;outline:none;margin-bottom:14px;
|
||||||
|
transition:border-color .15s}
|
||||||
|
input:focus{border-color:rgba(124,185,255,.5);box-shadow:0 0 0 3px rgba(124,185,255,.1)}
|
||||||
|
button{width:100%;padding:10px;border-radius:10px;border:none;background:rgba(124,185,255,.15);
|
||||||
|
border:1px solid rgba(124,185,255,.3);color:#7cb9ff;font-size:14px;font-weight:600;cursor:pointer;
|
||||||
|
transition:all .15s}
|
||||||
|
button:hover{background:rgba(124,185,255,.25)}
|
||||||
|
.err{color:#e94560;font-size:12px;margin-top:10px;display:none}
|
||||||
|
</style></head><body>
|
||||||
|
<div class="card">
|
||||||
|
<div class="logo">H</div>
|
||||||
|
<h1>Hermes</h1>
|
||||||
|
<p class="sub">Enter your password to continue</p>
|
||||||
|
<form onsubmit="return doLogin(event)">
|
||||||
|
<input type="password" id="pw" placeholder="Password" autofocus>
|
||||||
|
<button type="submit">Sign in</button>
|
||||||
|
</form>
|
||||||
|
<div class="err" id="err"></div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
async function doLogin(e){
|
||||||
|
e.preventDefault();
|
||||||
|
const pw=document.getElementById('pw').value;
|
||||||
|
const err=document.getElementById('err');
|
||||||
|
err.style.display='none';
|
||||||
|
try{
|
||||||
|
const res=await fetch('/api/auth/login',{method:'POST',
|
||||||
|
headers:{'Content-Type':'application/json'},
|
||||||
|
body:JSON.stringify({password:pw}),credentials:'include'});
|
||||||
|
const data=await res.json();
|
||||||
|
if(res.ok&&data.ok){window.location.href='/';}
|
||||||
|
else{err.textContent=data.error||'Invalid password';err.style.display='block';}
|
||||||
|
}catch(ex){err.textContent='Connection failed';err.style.display='block';}
|
||||||
|
}
|
||||||
|
</script></body></html>'''
|
||||||
|
|
||||||
# ── GET routes ────────────────────────────────────────────────────────────────
|
# ── GET routes ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def handle_get(handler, parsed):
|
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'),
|
return t(handler, _INDEX_HTML_PATH.read_text(encoding='utf-8'),
|
||||||
content_type='text/html; charset=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':
|
if parsed.path == '/favicon.ico':
|
||||||
handler.send_response(204); handler.end_headers(); return True
|
handler.send_response(204); handler.end_headers(); return True
|
||||||
|
|
||||||
@@ -400,6 +463,36 @@ def handle_post(handler, parsed):
|
|||||||
if parsed.path == '/api/session/import':
|
if parsed.path == '/api/session/import':
|
||||||
return _handle_session_import(handler, body)
|
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
|
return False # 404
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import traceback
|
|||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
from urllib.parse import urlparse
|
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.config import HOST, PORT, STATE_DIR, SESSION_DIR, DEFAULT_WORKSPACE
|
||||||
from api.helpers import j
|
from api.helpers import j
|
||||||
from api.routes import handle_get, handle_post
|
from api.routes import handle_get, handle_post
|
||||||
@@ -34,6 +35,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
self._req_t0 = time.time()
|
self._req_t0 = time.time()
|
||||||
try:
|
try:
|
||||||
parsed = urlparse(self.path)
|
parsed = urlparse(self.path)
|
||||||
|
if not check_auth(self, parsed): return
|
||||||
result = handle_get(self, parsed)
|
result = handle_get(self, parsed)
|
||||||
if result is False:
|
if result is False:
|
||||||
return j(self, {'error': 'not found'}, status=404)
|
return j(self, {'error': 'not found'}, status=404)
|
||||||
@@ -44,6 +46,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
self._req_t0 = time.time()
|
self._req_t0 = time.time()
|
||||||
try:
|
try:
|
||||||
parsed = urlparse(self.path)
|
parsed = urlparse(self.path)
|
||||||
|
if not check_auth(self, parsed): return
|
||||||
result = handle_post(self, parsed)
|
result = handle_post(self, parsed)
|
||||||
if result is False:
|
if result is False:
|
||||||
return j(self, {'error': 'not found'}, status=404)
|
return j(self, {'error': 'not found'}, status=404)
|
||||||
|
|||||||
@@ -282,7 +282,13 @@
|
|||||||
<option value="ctrl+enter">Ctrl+Enter (Enter for newline)</option>
|
<option value="ctrl+enter">Ctrl+Enter (Enter for newline)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="settings-field" style="border-top:1px solid var(--border);padding-top:12px;margin-top:8px">
|
||||||
|
<label for="settingsPassword">Access Password</label>
|
||||||
|
<div style="font-size:11px;color:var(--muted);margin-bottom:6px">Set a password to require login. Leave blank to disable auth.</div>
|
||||||
|
<input type="password" id="settingsPassword" placeholder="Enter new password (or blank to disable)" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px;font-size:13px">
|
||||||
|
</div>
|
||||||
<button class="sm-btn" onclick="saveSettings()" style="margin-top:12px;width:100%;padding:8px;font-weight:600">Save Settings</button>
|
<button class="sm-btn" onclick="saveSettings()" style="margin-top:12px;width:100%;padding:8px;font-weight:600">Save Settings</button>
|
||||||
|
<button class="sm-btn" id="btnSignOut" onclick="signOut()" style="margin-top:6px;width:100%;padding:8px;font-weight:600;color:var(--accent);border-color:rgba(233,69,96,.3);display:none">Sign Out</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -649,6 +649,15 @@ async function loadSettingsPanel(){
|
|||||||
// Send key preference
|
// Send key preference
|
||||||
const sendKeySel=$('settingsSendKey');
|
const sendKeySel=$('settingsSendKey');
|
||||||
if(sendKeySel) sendKeySel.value=settings.send_key||'enter';
|
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){
|
}catch(e){
|
||||||
showToast('Failed to load settings: '+e.message);
|
showToast('Failed to load settings: '+e.message);
|
||||||
}
|
}
|
||||||
@@ -658,10 +667,28 @@ async function saveSettings(){
|
|||||||
const model=($('settingsModel')||{}).value;
|
const model=($('settingsModel')||{}).value;
|
||||||
const workspace=($('settingsWorkspace')||{}).value;
|
const workspace=($('settingsWorkspace')||{}).value;
|
||||||
const sendKey=($('settingsSendKey')||{}).value;
|
const sendKey=($('settingsSendKey')||{}).value;
|
||||||
|
const pw=($('settingsPassword')||{}).value;
|
||||||
const body={};
|
const body={};
|
||||||
if(model) body.default_model=model;
|
if(model) body.default_model=model;
|
||||||
if(workspace) body.default_workspace=workspace;
|
if(workspace) body.default_workspace=workspace;
|
||||||
if(sendKey) body.send_key=sendKey;
|
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{
|
try{
|
||||||
await api('/api/settings',{method:'POST',body:JSON.stringify(body)});
|
await api('/api/settings',{method:'POST',body:JSON.stringify(body)});
|
||||||
window._sendKey=sendKey||'enter';
|
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)
|
// Close settings on overlay click (not panel click)
|
||||||
document.addEventListener('click',e=>{
|
document.addEventListener('click',e=>{
|
||||||
const overlay=$('settingsOverlay');
|
const overlay=$('settingsOverlay');
|
||||||
|
|||||||
108
tests/test_sprint19.py
Normal file
108
tests/test_sprint19.py
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user