diff --git a/Docs/Backend-Development.md b/Docs/Backend-Development.md index 3b766cd..4219428 100644 --- a/Docs/Backend-Development.md +++ b/Docs/Backend-Development.md @@ -1085,7 +1085,39 @@ The login endpoint (`POST /api/auth/login`) is protected against brute-force att --- -## 13. File I/O Conventions +## 12. Password Hashing + +The master password is hashed using **bcrypt** with an auto-generated salt. All password validation uses the models in `app.models.auth` and `app.models.setup`. + +### The 72-Byte Bcrypt Limitation + +**Important:** bcrypt silently truncates all input at **72 bytes** before hashing. This means: +- A user who sets a 100-character password is actually authenticated by only the first 72 bytes +- Extra characters beyond 72 bytes provide **zero additional security** +- An attacker who has reduced their search space to 72 bytes can brute-force the password more efficiently than intended + +**Solution:** Both password fields enforce a **maximum length of 72 bytes**: +- `LoginRequest.password` — max 72 characters (enforced via Pydantic `Field(max_length=72)`) +- `SetupRequest.master_password` — max 72 characters (enforced via Pydantic `Field(max_length=72)`) + +**Validation flow:** +1. Frontend → hashes password with SHA256 using `SubtleCrypto` before transmission +2. Backend receives SHA256 hash, validates length (≤ 72 bytes) +3. Backend → hashes with bcrypt using `run_blocking(bcrypt.hashpw)` to avoid event loop stall +4. Hash stored in SQLite `settings` table + +**If a password exceeds 72 bytes:** +- Pydantic raises `ValidationError` with error code `string_too_long` +- The router returns **HTTP 422 Unprocessable Entity** +- The frontend should inform the user to choose a shorter password + +**Implementation:** +- Models: `app.models.auth.LoginRequest`, `app.models.setup.SetupRequest` +- Service layer: `app.services.auth_service._check_password()`, `app.services.setup_service.run_setup()` + +--- + +## 14. File I/O Conventions All file write operations to critical configuration files must be **atomic** to prevent corruption if the process is killed mid-write. @@ -1163,7 +1195,7 @@ atomic_write(path, updated_content) # Atomic write, auto-cleanup on error --- -## 14. Git & Workflow +## 15. Git & Workflow - **Branch naming:** `feature/`, `fix/`, `chore/`. - **Commit messages:** imperative tense, max 72 chars first line (`Add jail reload endpoint`, `Fix ban history query`). @@ -1173,7 +1205,7 @@ atomic_write(path, updated_content) # Atomic write, auto-cleanup on error --- -## 15. Coding Principles +## 16. Coding Principles These principles are **non-negotiable**. Every backend contributor must internalise and apply them daily. @@ -1560,7 +1592,7 @@ When user-supplied URLs are fetched by the backend, validate them before making --- -## 16. Quick Reference — Do / Don't +## 17. Quick Reference — Do / Don't | Do | Don't | |---|---| diff --git a/Docs/Features.md b/Docs/Features.md index 5b711ae..0be8094 100644 --- a/Docs/Features.md +++ b/Docs/Features.md @@ -14,7 +14,7 @@ A web application to monitor, manage, and configure fail2ban from a clean, acces ### Options -- **Master Password** — Set a single global password that protects the entire web interface. +- **Master Password** — Set a single global password that protects the entire web interface. Must be between 8 and 72 characters long (72-byte limit is due to bcrypt truncation) and include one uppercase letter, one number, and one special character from `!@#$%^&*()`. - **Database Path** — Define where the application stores its own SQLite database. - **fail2ban Connection** — Specify how the application connects to the running fail2ban instance (socket path or related settings). - **General Preferences** — Any additional application-level settings such as default time zone, date format, or session duration. diff --git a/backend/app/models/auth.py b/backend/app/models/auth.py index 8527de3..1ccb11f 100644 --- a/backend/app/models/auth.py +++ b/backend/app/models/auth.py @@ -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): diff --git a/backend/app/models/setup.py b/backend/app/models/setup.py index 4b65c8d..9b9ff80 100644 --- a/backend/app/models/setup.py +++ b/backend/app/models/setup.py @@ -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): diff --git a/backend/tests/test_models.py b/backend/tests/test_models.py index 66ebd1e..2d19efc 100644 --- a/backend/tests/test_models.py +++ b/backend/tests/test_models.py @@ -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)