fix: return 401 (not 500) when bcrypt hash is malformed (#21)
- verify_password catches ValueError from passlib so a mangled hash ($ signs stripped by shell/Docker) returns False instead of crashing - Settings.check_password_hash logs a clear startup error with fix instructions when AUTH_PASSWORD_HASH doesn't start with '$2'
This commit is contained in:
@@ -1,8 +1,12 @@
|
|||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pydantic import model_validator
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
|
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
|
||||||
@@ -19,6 +23,17 @@ class Settings(BaseSettings):
|
|||||||
auth_username: str = "admin"
|
auth_username: str = "admin"
|
||||||
auth_password_hash: str = ""
|
auth_password_hash: str = ""
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def check_password_hash(self) -> "Settings":
|
||||||
|
h = self.auth_password_hash
|
||||||
|
if h and not h.startswith("$2"):
|
||||||
|
logger.error(
|
||||||
|
"AUTH_PASSWORD_HASH looks invalid (does not start with '$2b$'). "
|
||||||
|
"bcrypt hashes contain '$' signs — wrap the value in single quotes "
|
||||||
|
"in your .env file: AUTH_PASSWORD_HASH='$2b$12$...'"
|
||||||
|
)
|
||||||
|
return self
|
||||||
|
|
||||||
# Scanner
|
# Scanner
|
||||||
scanner_ranges: list[str] = ["192.168.1.0/24"]
|
scanner_ranges: list[str] = ["192.168.1.0/24"]
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,10 @@ pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
|||||||
|
|
||||||
|
|
||||||
def verify_password(plain: str, hashed: str) -> bool:
|
def verify_password(plain: str, hashed: str) -> bool:
|
||||||
return bool(pwd_context.verify(plain, hashed))
|
try:
|
||||||
|
return bool(pwd_context.verify(plain, hashed))
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def hash_password(password: str) -> str:
|
def hash_password(password: str) -> str:
|
||||||
|
|||||||
@@ -56,3 +56,15 @@ async def test_service_key_disabled_when_not_configured(client: AsyncClient):
|
|||||||
settings.mcp_service_key = ""
|
settings.mcp_service_key = ""
|
||||||
res = await client.get("/api/v1/nodes", headers={"X-MCP-Service-Key": "any-key"})
|
res = await client.get("/api/v1/nodes", headers={"X-MCP-Service-Key": "any-key"})
|
||||||
assert res.status_code == 401
|
assert res.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
async def test_login_with_malformed_hash_returns_401_not_500(client: AsyncClient):
|
||||||
|
"""Malformed hash (e.g. $ stripped by shell) must not crash with 500."""
|
||||||
|
from app.core.config import settings
|
||||||
|
original = settings.auth_password_hash
|
||||||
|
settings.auth_password_hash = "2b12RtMbyw17l4N5UGzeXMNAWu" # $ signs stripped
|
||||||
|
try:
|
||||||
|
res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin"})
|
||||||
|
assert res.status_code == 401
|
||||||
|
finally:
|
||||||
|
settings.auth_password_hash = original
|
||||||
|
|||||||
Reference in New Issue
Block a user