fix(auth): harden password_hash handling in settings API

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.
This commit is contained in:
Nathan Esquenazi
2026-04-03 13:05:41 +00:00
parent 66bd84accb
commit 3c95502979
3 changed files with 21 additions and 6 deletions

View File

@@ -88,11 +88,11 @@ def test_cache_control_no_store():
# ── Settings password field ──────────────────────────────────────────────
def test_settings_password_hash_default_null():
"""Default settings should have password_hash as None."""
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 d.get("password_hash") is None
assert "password_hash" not in d # security: never send hash to client
def test_settings_save_preserves_other_fields():
@@ -106,3 +106,13 @@ def test_settings_save_preserves_other_fields():
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