TASK-031: Enforce bcrypt 72-byte password limit
Bcrypt silently truncates passwords at 72 bytes, so passwords longer than 72 characters provide no additional security. This commit enforces the 72-byte maximum across the authentication and setup flows. Changes: - Add max_length=72 to LoginRequest.password and SetupRequest.master_password - Update field validator in SetupRequest to explicitly check max_length - Add comprehensive tests for password length validation (6 new test cases) - Document the 72-byte limitation in Features.md (master password options) - Add new section 12 'Password Hashing' in Backend-Development.md explaining: - The bcrypt truncation behavior - Why the limit is enforced - The validation flow from frontend to backend - What happens when passwords exceed the limit All existing tests pass, no regressions introduced. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -11,7 +11,11 @@ class LoginRequest(BaseModel):
|
||||
|
||||
model_config = ConfigDict(strict=True)
|
||||
|
||||
password: str = Field(..., description="Master password to authenticate with.")
|
||||
password: str = Field(
|
||||
...,
|
||||
max_length=72,
|
||||
description="Master password to authenticate with (max 72 bytes due to bcrypt truncation).",
|
||||
)
|
||||
|
||||
|
||||
class LoginResponse(BaseModel):
|
||||
|
||||
@@ -14,7 +14,8 @@ class SetupRequest(BaseModel):
|
||||
master_password: str = Field(
|
||||
...,
|
||||
min_length=8,
|
||||
description="Master password that protects the BanGUI interface.",
|
||||
max_length=72,
|
||||
description="Master password that protects the BanGUI interface (max 72 bytes due to bcrypt truncation).",
|
||||
)
|
||||
|
||||
@field_validator("master_password")
|
||||
@@ -22,6 +23,8 @@ class SetupRequest(BaseModel):
|
||||
def validate_master_password(cls, value: str) -> str:
|
||||
if len(value) < 8:
|
||||
raise ValueError("Password must be at least 8 characters long.")
|
||||
if len(value) > 72:
|
||||
raise ValueError("Password must not exceed 72 bytes (bcrypt limitation).")
|
||||
if not any(char.isupper() for char in value):
|
||||
raise ValueError("Password must include at least one uppercase letter.")
|
||||
if not any(char.isdigit() for char in value):
|
||||
|
||||
@@ -289,3 +289,78 @@ def test_global_config_response_log_target_invalid_path(_mock_allowed_dirs: None
|
||||
)
|
||||
error_msg = str(exc_info.value)
|
||||
assert "outside allowed directories" in error_msg
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# LoginRequest and SetupRequest password validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_login_request_password_accepted_at_72_bytes() -> None:
|
||||
"""LoginRequest accepts passwords exactly 72 bytes long."""
|
||||
from app.models.auth import LoginRequest
|
||||
|
||||
password_72 = "a" * 72
|
||||
req = LoginRequest(password=password_72)
|
||||
assert req.password == password_72
|
||||
|
||||
|
||||
def test_login_request_password_rejected_over_72_bytes() -> None:
|
||||
"""LoginRequest rejects passwords exceeding 72 bytes."""
|
||||
from app.models.auth import LoginRequest
|
||||
|
||||
password_73 = "a" * 73
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
LoginRequest(password=password_73)
|
||||
error_msg = str(exc_info.value)
|
||||
assert "at most 72 characters" in error_msg or "max_length" in error_msg
|
||||
|
||||
|
||||
def test_setup_request_master_password_accepted_at_72_bytes() -> None:
|
||||
"""SetupRequest accepts master_password exactly 72 bytes long."""
|
||||
from app.models.setup import SetupRequest
|
||||
|
||||
password_72 = "Password1!" + "a" * 61 # 72 chars total with required complexity
|
||||
req = SetupRequest(master_password=password_72)
|
||||
assert req.master_password == password_72
|
||||
|
||||
|
||||
def test_setup_request_master_password_rejected_over_72_bytes() -> None:
|
||||
"""SetupRequest rejects master_password exceeding 72 bytes."""
|
||||
from app.models.setup import SetupRequest
|
||||
|
||||
password_73 = "Password1!" + "a" * 63 # 73 chars total
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
SetupRequest(master_password=password_73)
|
||||
error_msg = str(exc_info.value)
|
||||
assert "at most 72 characters" in error_msg or "string_too_long" in error_msg
|
||||
|
||||
|
||||
def test_setup_request_master_password_min_length_still_enforced() -> None:
|
||||
"""SetupRequest still enforces minimum password length (8 characters)."""
|
||||
from app.models.setup import SetupRequest
|
||||
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
SetupRequest(master_password="Short1!")
|
||||
error_msg = str(exc_info.value)
|
||||
assert "8 characters" in error_msg
|
||||
|
||||
|
||||
def test_setup_request_master_password_complexity_still_enforced() -> None:
|
||||
"""SetupRequest still enforces password complexity requirements."""
|
||||
from app.models.setup import SetupRequest
|
||||
|
||||
# Missing uppercase
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
SetupRequest(master_password="password123!")
|
||||
assert "uppercase" in str(exc_info.value)
|
||||
|
||||
# Missing number
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
SetupRequest(master_password="Password!")
|
||||
assert "number" in str(exc_info.value)
|
||||
|
||||
# Missing special character
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
SetupRequest(master_password="Password1")
|
||||
assert "special character" in str(exc_info.value)
|
||||
|
||||
Reference in New Issue
Block a user