refactoring-backend #3
@@ -734,6 +734,27 @@ BANGUI_ALLOWED_LOG_DIRS="/var/log,/config/log" # Default
|
||||
BANGUI_ALLOWED_LOG_DIRS="/var/log,/config/log,/home/app/logs" # Custom directory
|
||||
```
|
||||
|
||||
### Log Target Validation (fail2ban)
|
||||
|
||||
The `log_target` field on the global config endpoint (`PUT /api/config/global`) is critical for security because fail2ban runs as root. Users can only set log targets to:
|
||||
|
||||
1. **Special values:** `STDOUT`, `STDERR`, `SYSLOG` (case-insensitive)
|
||||
2. **File paths:** Must resolve to one of the configured allowed directories (same allowlist as log paths)
|
||||
|
||||
**Why This Matters:**
|
||||
- fail2ban creates/opens files with root privileges. Without validation, an attacker could write to arbitrary system paths (e.g., `/etc/cron.d/malicious_script`).
|
||||
- Validation occurs at **both** the Pydantic model layer (`GlobalConfigUpdate.validate_log_target()`) **and** the service layer (`update_global_config()`) for defense in depth.
|
||||
- This prevents both HTTP and non-HTTP attack vectors.
|
||||
|
||||
**Implementation:**
|
||||
```python
|
||||
# Model layer: Automatic validation via @field_validator
|
||||
update = GlobalConfigUpdate(log_target="/etc/passwd") # Raises ValidationError → HTTP 422
|
||||
|
||||
# Service layer: Defense in depth
|
||||
await config_service.update_global_config(socket_path, update) # Validates again before sending to fail2ban
|
||||
```
|
||||
|
||||
### Login Rate Limiting
|
||||
|
||||
The login endpoint (`POST /api/auth/login`) is protected against brute-force attacks using an in-memory rate limiter.
|
||||
|
||||
@@ -1,32 +1,3 @@
|
||||
## TASK-019 — `session_secret` has no minimum-length enforcement
|
||||
|
||||
**Severity:** Medium
|
||||
|
||||
### Where found
|
||||
`backend/app/config.py` — `session_secret: str = Field(..., description="...")`. No `min_length` constraint.
|
||||
|
||||
### Why this is needed
|
||||
`session_secret` is the HMAC key used to sign all session tokens. A secret shorter than 32 characters (256 bits) significantly weakens HMAC-SHA256. The app currently accepts any non-empty string, including a single character.
|
||||
|
||||
### Goal
|
||||
Enforce a minimum secret length of 32 characters at startup.
|
||||
|
||||
### What to do
|
||||
1. Add `min_length=32` to the `session_secret` Field definition.
|
||||
2. Update the error message to explain: `"session_secret must be at least 32 characters. Generate one with: python -c \"import secrets; print(secrets.token_hex(32))\""`.
|
||||
|
||||
### Possible traps and issues
|
||||
- This is a breaking change for any existing deployment where the secret is shorter than 32 characters. Include a migration note in the changelog.
|
||||
- The debug compose file uses `dev-secret-do-not-use-in-production` (42 chars) — this already passes the 32-char check, so dev environments are unaffected.
|
||||
|
||||
### Docs changes needed
|
||||
- `Backend-Development.md` — document the `session_secret` constraint.
|
||||
|
||||
### Doc references
|
||||
- [Backend-Development.md](Backend-Development.md) — configuration reference
|
||||
|
||||
---
|
||||
|
||||
## TASK-020 — `log_target` accepts arbitrary paths — root file write via fail2ban (CRITICAL)
|
||||
|
||||
**Severity:** Critical
|
||||
|
||||
@@ -59,6 +59,7 @@ from app.utils.fail2ban_response import (
|
||||
ok,
|
||||
to_dict,
|
||||
)
|
||||
from app.utils.path_utils import validate_log_target
|
||||
|
||||
log: structlog.stdlib.BoundLogger = structlog.get_logger()
|
||||
|
||||
@@ -421,8 +422,15 @@ async def update_global_config(socket_path: str, update: GlobalConfigUpdate) ->
|
||||
|
||||
Raises:
|
||||
ConfigOperationError: If a ``set`` command is rejected.
|
||||
ConfigValidationError: If log_target validation fails.
|
||||
~app.utils.fail2ban_client.Fail2BanConnectionError: Socket unreachable.
|
||||
"""
|
||||
if update.log_target is not None:
|
||||
try:
|
||||
validate_log_target(update.log_target)
|
||||
except ValueError as e:
|
||||
raise ConfigValidationError(str(e)) from e
|
||||
|
||||
client = Fail2BanClient(socket_path=socket_path, timeout=FAIL2BAN_SOCKET_TIMEOUT)
|
||||
|
||||
async def _set_global(key: str, value: Fail2BanToken) -> None:
|
||||
|
||||
@@ -38,3 +38,25 @@ def validate_log_path(log_path: str) -> str:
|
||||
raise ValueError(
|
||||
f"Log path {log_path!r} is outside allowed directories: {allowed_dirs_str}"
|
||||
)
|
||||
|
||||
|
||||
def validate_log_target(log_target: str) -> str:
|
||||
"""Validate that a log target is either a special value or a valid file path.
|
||||
|
||||
Accepts special values (STDOUT, STDERR, SYSLOG) and file paths that resolve
|
||||
to one of the configured allowed log directories.
|
||||
|
||||
Args:
|
||||
log_target: The log target to validate.
|
||||
|
||||
Returns:
|
||||
The validated log target (unchanged).
|
||||
|
||||
Raises:
|
||||
ValueError: If the target is not a special value and not in allowed directories.
|
||||
"""
|
||||
if log_target.upper() in ("STDOUT", "STDERR", "SYSLOG"):
|
||||
return log_target
|
||||
|
||||
return validate_log_path(log_target)
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ def _mock_allowed_dirs(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
database_path=":memory:",
|
||||
fail2ban_socket="/tmp/fake.sock",
|
||||
fail2ban_config_dir="/tmp/fail2ban",
|
||||
session_secret="test-secret-key-do-not-use",
|
||||
session_secret="a" * 32,
|
||||
)
|
||||
|
||||
monkeypatch.setattr("app.models.config.get_settings", mock_get_settings)
|
||||
|
||||
@@ -29,7 +29,7 @@ from app.models.config import (
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_SETUP_PAYLOAD = {
|
||||
"master_password": "testpassword1",
|
||||
"master_password": "Testpass1!",
|
||||
"database_path": "bangui.db",
|
||||
"fail2ban_socket": "/var/run/fail2ban/fail2ban.sock",
|
||||
"timezone": "UTC",
|
||||
@@ -43,7 +43,7 @@ async def config_client(tmp_path: Path) -> AsyncClient: # type: ignore[misc]
|
||||
settings = Settings(
|
||||
database_path=str(tmp_path / "config_test.db"),
|
||||
fail2ban_socket="/tmp/fake.sock",
|
||||
session_secret="test-config-secret",
|
||||
session_secret="test-secret-key-do-not-use-in-production",
|
||||
session_duration_minutes=60,
|
||||
timezone="UTC",
|
||||
log_level="debug",
|
||||
|
||||
@@ -36,10 +36,11 @@ def _mock_settings(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
database_path=":memory:",
|
||||
fail2ban_socket="/tmp/fake.sock",
|
||||
fail2ban_config_dir="/tmp/fail2ban",
|
||||
session_secret="test-secret-key-do-not-use",
|
||||
session_secret="test-secret-key-do-not-use-in-production",
|
||||
)
|
||||
|
||||
monkeypatch.setattr("app.models.config.get_settings", mock_get_settings)
|
||||
monkeypatch.setattr("app.utils.path_utils.get_settings", mock_get_settings)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -519,6 +520,34 @@ class TestUpdateGlobalConfig:
|
||||
cmd = next(c for c in sent if len(c) >= 3 and c[1] == "loglevel")
|
||||
assert cmd[2] == "DEBUG"
|
||||
|
||||
async def test_invalid_log_target_raises_config_validation_error(self) -> None:
|
||||
"""update_global_config rejects invalid log_target from model validation."""
|
||||
from pydantic import ValidationError
|
||||
|
||||
with pytest.raises(ValidationError, match="outside allowed directories"):
|
||||
GlobalConfigUpdate(log_target="/etc/passwd")
|
||||
|
||||
async def test_valid_special_log_target(self) -> None:
|
||||
"""update_global_config accepts special log_target values."""
|
||||
sent: list[list[Any]] = []
|
||||
|
||||
async def _send(command: list[Any]) -> Any:
|
||||
sent.append(command)
|
||||
return (0, "OK")
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self, **_kw: Any) -> None:
|
||||
self.send = AsyncMock(side_effect=_send)
|
||||
|
||||
for target in ["STDOUT", "STDERR", "SYSLOG"]:
|
||||
sent.clear()
|
||||
update = GlobalConfigUpdate(log_target=target)
|
||||
with patch("app.services.config_service.Fail2BanClient", _FakeClient):
|
||||
await config_service.update_global_config(_SOCKET, update)
|
||||
|
||||
cmd = next(c for c in sent if len(c) >= 3 and c[1] == "logtarget")
|
||||
assert cmd[2] == target
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# test_regex (synchronous)
|
||||
|
||||
Reference in New Issue
Block a user