Three security issues found during review:
1. password_hash exposed via GET /api/settings
load_settings() returned all fields including the stored hash.
Fix: strip password_hash from the response in routes.py.
2. password_hash directly settable via POST /api/settings
'password_hash' was in _SETTINGS_ALLOWED_KEYS, so an attacker
could POST {password_hash: 'X'} to hijack auth without knowing
the current password.
Fix: exclude password_hash from _SETTINGS_ALLOWED_KEYS.
(Use _set_password for the legitimate hash-and-store path.)
3. Security headers missing from /api/auth/login and /api/auth/logout
These endpoints built their responses manually (bypassing j()),
so they omitted X-Content-Type-Options etc.
Fix: call _security_headers() before end_headers() on both.
Tests updated: renamed test to assert key absent (not just None),
added new test verifying direct password_hash POST is blocked.
119 lines
4.2 KiB
Python
119 lines
4.2 KiB
Python
"""
|
|
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_not_exposed():
|
|
"""GET /api/settings must never expose the stored password hash."""
|
|
d, status, _ = get("/api/settings")
|
|
assert status == 200
|
|
assert "password_hash" not in d # security: never send hash to client
|
|
|
|
|
|
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
|
|
|
|
|
|
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
|