* Security: harden auth, CSRF, SSRF, XSS, and env race conditions Twelve fixes from a full security audit: CRITICAL - Add CSRF Origin/Referer validation on all POST endpoints (prevents cross-origin abuse of self-update, settings, file ops) HIGH - Unify password hashing: config.py now uses PBKDF2 (600k iters) instead of single-iteration SHA-256 - Add per-IP rate limiting on login (5 attempts/60s, 429 on excess) MEDIUM - Validate session IDs as hex-only before filesystem operations (prevents path traversal via crafted session ID) - SSRF: resolve DNS before private-IP check in model fetching (prevents DNS rebinding to internal services) - Warn loudly when binding non-loopback without password set - SSE env var mutations: wrap sync chat + streaming restore in _ENV_LOCK - Force Content-Disposition:attachment for HTML/XHTML/SVG uploads (prevents stored XSS via uploaded files) LOW - Extend HMAC session signature from 64 to 128 bits - Add resolve()+relative_to() check on skills path construction - Set Secure flag on session cookie when connection is HTTPS - Sanitize exception messages to strip filesystem paths No breaking changes. All fixes are backward-compatible. * fix: use getattr for Secure cookie SSL detection handler.request.getpeercert raises AttributeError on plain sockets (non-SSL). Use getattr(..., None) to safely check for SSL. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * tests: add sprint 29 security hardening coverage (PR #171) 33 tests covering all 12 security fixes: - CSRF origin/referer validation - Login rate limiting (5 attempts/60s) - Session ID hex validation (path traversal prevention) - Error path sanitization (_sanitize_error) - Secure cookie getattr safety - HMAC signature length (64->128 bit) - Skills path traversal prevention - Content-Disposition for HTML/SVG/XHTML - PBKDF2 password hashing verification - Non-loopback startup warning - SSRF DNS guard code presence - _ENV_LOCK export from streaming module * release: v0.39.0 — security hardening, 12 fixes (#171) --------- Co-authored-by: betamod <matthew.sloly@gmail.com> Co-authored-by: Nathan Esquenazi <nesquena@gmail.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
92 lines
3.8 KiB
Python
92 lines
3.8 KiB
Python
"""
|
|
Hermes Web UI -- Main server entry point.
|
|
Thin routing shell: imports Handler, delegates to api/routes.py, runs server.
|
|
All business logic lives in api/*.
|
|
"""
|
|
import time
|
|
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
|
|
|
|
|
|
class Handler(BaseHTTPRequestHandler):
|
|
server_version = 'HermesWebUI/0.2'
|
|
def log_message(self, fmt, *args): pass # suppress default Apache-style log
|
|
|
|
def log_request(self, code: str='-', size: str='-') -> None:
|
|
"""Structured JSON logs for each request."""
|
|
import json as _json
|
|
duration_ms = round((time.time() - getattr(self, '_req_t0', time.time())) * 1000, 1)
|
|
record = _json.dumps({
|
|
'ts': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()),
|
|
'method': self.command or '-',
|
|
'path': self.path or '-',
|
|
'status': int(code) if str(code).isdigit() else code,
|
|
'ms': duration_ms,
|
|
})
|
|
print(f'[webui] {record}', flush=True)
|
|
|
|
def do_GET(self) -> None:
|
|
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)
|
|
except Exception as e:
|
|
print(f'[webui] ERROR {self.command} {self.path}\n' + traceback.format_exc(), flush=True)
|
|
return j(self, {'error': 'Internal server error'}, status=500)
|
|
|
|
def do_POST(self) -> None:
|
|
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)
|
|
except Exception as e:
|
|
print(f'[webui] ERROR {self.command} {self.path}\n' + traceback.format_exc(), flush=True)
|
|
return j(self, {'error': 'Internal server error'}, status=500)
|
|
|
|
|
|
def main() -> None:
|
|
from api.config import print_startup_config, verify_hermes_imports, _HERMES_FOUND
|
|
|
|
print_startup_config()
|
|
|
|
# Security: warn if binding non-loopback without authentication
|
|
from api.auth import is_auth_enabled
|
|
if HOST not in ('127.0.0.1', '::1', 'localhost') and not is_auth_enabled():
|
|
print(f'[!!] WARNING: Binding to {HOST} with NO PASSWORD SET.', flush=True)
|
|
print(f' Anyone on the network can access your filesystem and agent.', flush=True)
|
|
print(f' Set a password via Settings or HERMES_WEBUI_PASSWORD env var.', flush=True)
|
|
print(f' To suppress: bind to 127.0.0.1 or set a password.', flush=True)
|
|
|
|
ok, missing, errors = verify_hermes_imports()
|
|
if not ok and _HERMES_FOUND:
|
|
print(f'[!!] Warning: Hermes agent found but missing modules: {missing}', flush=True)
|
|
for mod, err in errors.items():
|
|
print(f' {mod}: {err}', flush=True)
|
|
print(' Agent features may not work correctly.', flush=True)
|
|
|
|
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
|
SESSION_DIR.mkdir(parents=True, exist_ok=True)
|
|
DEFAULT_WORKSPACE.mkdir(parents=True, exist_ok=True)
|
|
httpd = ThreadingHTTPServer((HOST, PORT), Handler)
|
|
print(f' Hermes Web UI listening on http://{HOST}:{PORT}', flush=True)
|
|
if HOST == '127.0.0.1':
|
|
print(f' Remote access: ssh -N -L {PORT}:127.0.0.1:{PORT} <user>@<your-server>', flush=True)
|
|
print(f' Then open: http://localhost:{PORT}', flush=True)
|
|
print('', flush=True)
|
|
httpd.serve_forever()
|
|
|
|
if __name__ == '__main__':
|
|
main()
|