_sessions is an in-memory dict, so every process restart (launchd bounce,
systemd restart, container recycle) invalidates all active browser sessions.
Users get 401 on every authenticated endpoint until they clear cookies.
The HMAC signing key already persists to STATE_DIR/.signing_key via atomic
owner-only write. This PR applies the same pattern to the session table:
- _load_sessions(): reads .sessions.json on module import, prunes expired
entries, tolerates missing/malformed files (returns {} on any error)
- _save_sessions(): atomic write via tempfile + os.replace(), chmod 0600,
mirrors .signing_key write pattern exactly
- create_session(): saves after inserting new token
- invalidate_session(): saves after removing token (only if token existed)
- _prune_expired_sessions(): saves only when entries are actually removed
Cookie format and signing are unchanged; existing sessions survive upgrade.
6 regression tests cover: restart survival, invalidation persistence,
expiry pruning on load, 0600 permissions, corrupt-file tolerance.
Co-authored with Claude Sonnet 4.6 / Anthropic.
* fix(auth): prune expired sessions on every verify to prevent memory leak
The in-memory _sessions dict accumulated expired tokens indefinitely —
entries were only removed when that specific token was verified. Add a
lazy _prune_expired_sessions() call at the top of verify_session() so
all expired entries are swept during normal traffic.
Addresses #192.
* test(auth): add 8 unit tests for session lifecycle and lazy pruning
Tests verify:
- Fresh session creation and validation
- Expired entries are pruned during verify_session() calls
- Valid sessions are never removed by pruning
- Empty dict is safe for pruning
- Session TTL matches expected 24-hour window
- invalidate_session() actually removes the token
- Invalidating non-existent tokens is safe
* security: fix four audit findings -- env race, signing key, upload traversal, password hash
1. Race condition in os.environ (HIGH): Per-session _agent_lock didn't
prevent cross-session env writes from interleaving. Added global
_ENV_LOCK in streaming.py that serializes the entire env save/restore
block across all sessions.
2. Predictable signing key (MEDIUM): sha256(STATE_DIR) was deterministic.
Now generates a random 32-byte key on first startup and persists it to
STATE_DIR/.signing_key (chmod 600). Existing sessions invalidated on
first restart (acceptable for a security fix).
3. Upload path traversal (MEDIUM): Filename '..' survived the regex
sanitization (dots are allowed chars). Added explicit rejection of
dot-only names and safe_resolve_ws() check to verify the resolved
path stays within the workspace.
4. Weak password hashing (MEDIUM): Replaced bare SHA-256 with PBKDF2-
SHA256 (600k iterations per OWASP). Uses stdlib hashlib.pbkdf2_hmac,
no new dependencies. Note: existing passwords must be re-set after
this change (hash format changed).
Closes#106
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: use random signing key as PBKDF2 salt (replaces predictable STATE_DIR salt)
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
'/' and '/index.html' were in PUBLIC_PATHS, so setting a password
and refreshing the root URL would show the app blank (JS loaded
but all API calls returned 401) instead of redirecting to /login.
Root and index.html must be protected paths so the browser gets a
302 -> /login when auth is active and no valid session cookie exists.
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>