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>
2026-04-03 12:53:26 +00:00
|
|
|
"""
|
|
|
|
|
Sprint 19 Tests: auth/login, security headers, request size limit.
|
|
|
|
|
"""
|
|
|
|
|
import json, urllib.error, urllib.request
|
|
|
|
|
|
2026-04-14 19:04:48 +00:00
|
|
|
from tests._pytest_port import BASE
|
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>
2026-04-03 12:53:26 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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"
|
|
|
|
|
|
|
|
|
|
|
v0.50.25: mobile scroll, import timestamps, profile security, mic fallback (#404)
* fix: restore mobile chat scrolling and drawer close (#397)
- static/style.css: add min-height:0 to .layout and .main (flex shrink chain fix for mobile scroll)
- static/style.css: add -webkit-overflow-scrolling:touch, touch-action:pan-y, overscroll-behavior-y:contain to .messages
- static/boot.js: call closeMobileSidebar() on new-conversation button onclick and Ctrl+K shortcut
- tests/test_mobile_layout.py: 41 new lines covering all three CSS fixes and both JS call sites
Original PR by @Jordan-SkyLF
* fix: preserve imported session timestamps (#395)
- api/models.py: add touch_updated_at: bool = True param to Session.save(); import_cli_session() accepts created_at/updated_at kwargs and saves with touch_updated_at=False
- api/routes.py: extract created_at/updated_at from get_cli_sessions() metadata and forward to import_cli_session(); use touch_updated_at=False on post-import save
- tests/test_gateway_sync.py: +53 lines — integration test verifying imported session keeps original timestamp and sorts correctly vs newer sessions; also fix: add WebUI session file cleanup in finally block
Original PR by @Jordan-SkyLF
* fix(profiles): block path traversal in profile switch and delete flows (#399)
Master was vulnerable: switch_profile and delete_profile_api joined user-supplied profile
names directly into filesystem paths with no validation. An attacker could send
'../../etc/passwd' as a profile name to traverse outside the profiles directory.
- api/profiles.py: add _resolve_named_profile_home(name) — validates name with
^[a-z0-9][a-z0-9_-]{0,63}$ regex then enforces path containment via
candidate.resolve().relative_to(profiles_root); use in switch_profile()
- api/profiles.py: add _validate_profile_name() call to delete_profile_api() entry
- api/routes.py: add _validate_profile_name() call at HTTP handler level for
both /api/profile/switch and /api/profile/delete (fail-fast at API boundary)
- tests/test_profile_path_security.py: 3 tests — traversal rejected, valid name passes
Cherry-picked commit aae7a30 from @Hinotoi-agent (PR was 62 commits behind master)
* feat: add desktop microphone transcription fallback (#396)
Mic button now works in browsers that support getUserMedia/MediaRecorder but
lack SpeechRecognition (e.g. Firefox desktop, some Chromium builds).
- static/boot.js: detect _canRecordAudio (navigator.mediaDevices + getUserMedia + MediaRecorder);
keep mic button enabled when either SpeechRecognition or MediaRecorder is available;
MediaRecorder fallback records audio, sends blob to /api/transcribe, inserts transcript
into the composer; _stopMic() handles all three states (recognition, mediaRecorder, neither)
- api/upload.py: add transcribe_audio() helper — saves uploaded blob to temp file, calls
transcription_tools.transcribe_audio(), always cleans up temp file
- api/routes.py: add /api/transcribe POST handler — CSRF protected, auth-gated, 20MB limit,
returns {text:...} or {error:...}
- api/helpers.py: change Permissions-Policy microphone=() to microphone=(self) (required to
allow getUserMedia in the same origin)
- tests/test_voice_transcribe_endpoint.py: 87 new lines — 3 tests with mocked transcription
- tests/test_sprint19.py: +1 regression guard (microphone=(self) in Permissions-Policy)
- tests/test_sprint20.py: 3 updated tests for new fallback-capability checks
Original PR by @Jordan-SkyLF
* docs: v0.50.25 release — version badge and CHANGELOG
---------
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-14 05:11:45 +00:00
|
|
|
def test_permissions_policy_does_not_disable_microphone():
|
|
|
|
|
"""Permissions-Policy must not hard-disable microphone access for same-origin voice input."""
|
|
|
|
|
_, status, headers = get("/health")
|
|
|
|
|
assert status == 200
|
|
|
|
|
policy = headers.get("Permissions-Policy", "")
|
|
|
|
|
assert policy, "Permissions-Policy header missing"
|
|
|
|
|
assert "microphone=()" not in policy, \
|
|
|
|
|
"Permissions-Policy must not block microphone access or desktop/mobile voice input cannot work"
|
|
|
|
|
|
|
|
|
|
|
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>
2026-04-03 12:53:26 +00:00
|
|
|
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 ──────────────────────────────────────────────
|
|
|
|
|
|
2026-04-03 13:05:41 +00:00
|
|
|
def test_settings_password_hash_not_exposed():
|
|
|
|
|
"""GET /api/settings must never expose the stored password hash."""
|
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>
2026-04-03 12:53:26 +00:00
|
|
|
d, status, _ = get("/api/settings")
|
|
|
|
|
assert status == 200
|
2026-04-03 13:05:41 +00:00
|
|
|
assert "password_hash" not in d # security: never send hash to client
|
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>
2026-04-03 12:53:26 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
2026-04-03 13:05:41 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_settings_password_hash_not_directly_settable():
|
|
|
|
|
"""POST /api/settings with password_hash must not overwrite the stored hash."""
|
|
|
|
|
# Attempt to set a raw hash directly (attack vector)
|
|
|
|
|
post("/api/settings", {"password_hash": "deadbeef" * 8})
|
|
|
|
|
# Settings response must not expose it regardless
|
|
|
|
|
updated, status, _ = get("/api/settings")
|
|
|
|
|
assert status == 200
|
|
|
|
|
assert "password_hash" not in updated
|