refactoring-backend #3

Merged
lukas.pupkalipinski merged 403 commits from refactoring-backend into main 2026-05-20 20:23:46 +02:00
9 changed files with 470 additions and 66 deletions
Showing only changes of commit c2348d7075 - Show all commits

View File

@@ -675,8 +675,21 @@ BanGUI maintains its **own SQLite database** (separate from the fail2ban databas
- **Runtime state** (`RuntimeState` in `app.utils.runtime_state`) — stores mutable application state: `server_status` (fail2ban online/offline), `last_activation` (jail activation tracking), `pending_recovery` (crash detection), and `runtime_settings` (effective configuration). **⚠️ RuntimeState is process-local and only safe when BanGUI runs as a single asyncio worker.** Mutations must not span `await` points (cooperative scheduling within a single event loop is safe). In multi-worker deployments, each process has its own copy — logouts from worker A don't affect worker B's cache, health status updates are per-worker, and activation tracking is unreliable. BanGUI enforces single-worker mode (TASK-002) to prevent this issue. For future multi-worker support, replace RuntimeState with a shared coordination backend (Redis, shared memory, database). See `app/utils/runtime_state.py` module docstring for details.
- **Setup-completion flag** — once `is_setup_complete()` returns `True`, the result is stored in `app.state._setup_complete_cached`. The `SetupRedirectMiddleware` skips the DB query on all subsequent requests, removing 1 SQL query per request for the common post-setup case. The completion flag is only written after the runtime database is successfully initialized and all initial setup settings are persisted, preventing a failed setup from permanently bypassing the setup wizard.
---
### 6.1 CSRF Protection
State-mutating endpoints (POST, PUT, DELETE, PATCH) that use cookie-based authentication are protected against Cross-Site Request Forgery (CSRF) attacks via a **custom header check middleware**.
**Design:**
- For requests authenticated via the session cookie (not Bearer token), the `CsrfMiddleware` requires the custom header `X-BanGUI-Request: 1` to be present.
- The frontend API client automatically includes this header on all requests.
- Cross-site `fetch()` calls cannot set custom headers without CORS preflight, which the backend rejects for non-allowed origins, providing defense-in-depth.
- Safe HTTP methods (GET, HEAD, OPTIONS) bypass the check.
- Bearer token authentication (via `Authorization: Bearer` header) bypasses the check because tokens are not CSRF-vulnerable (they are not automatically sent on cross-origin requests).
- Requests missing the CSRF header receive a `403 Forbidden` response with detail: `"CSRF validation failed. Request rejected."`.
This mechanism complements the existing `SameSite=Lax` cookie policy, which blocks traditional `<form>` POST requests but does not protect against JavaScript-initiated requests on a subdomain or same-origin XSS injection.
---
## 7. Scheduling
APScheduler 4.x (async mode) manages recurring background tasks.

View File

@@ -768,6 +768,50 @@ environment:
**Important:** If `Secure=true` is set, browsers will reject the session cookie when the backend is served over HTTP. Ensure your nginx/reverse proxy terminates TLS and passes `X-Forwarded-Proto: https` so FastAPI knows the connection is secure.
### CSRF Protection Middleware
State-mutating endpoints (POST, PUT, DELETE, PATCH) authenticated via session cookies are protected by the `CsrfMiddleware`, which enforces a custom header check.
**How It Works:**
1. For every request using a mutating HTTP method, the middleware checks:
- Is this request authenticated via session cookie (not Bearer token)?
- If yes, require the custom header `X-BanGUI-Request: 1`.
- If missing or incorrect, return `403 Forbidden`.
2. **Bearer token requests** (via `Authorization: Bearer` header) bypass the check because tokens are not CSRF-vulnerable — they are never automatically sent on cross-origin requests.
3. **Safe HTTP methods** (GET, HEAD, OPTIONS) bypass the check.
4. **Cross-site protection:** Cross-site JavaScript (`fetch()` calls from other origins) cannot set custom headers without CORS preflight, which the backend rejects for non-allowed origins. This provides defense-in-depth against subdomain attacks and XSS injection.
**Implementation Location:**
- Middleware: `backend/app/middleware/csrf.py`
- Registered in: `backend/app/main.py` via `app.add_middleware(CsrfMiddleware)`
**Example:**
```python
# ✓ Cookie-authenticated POST with CSRF header — allowed
POST /api/bans
Cookie: bangui_session=...
X-BanGUI-Request: 1
# ✗ Cookie-authenticated POST without CSRF header — rejected with 403
POST /api/bans
Cookie: bangui_session=...
(no X-BanGUI-Request header)
# ✓ Bearer token authentication without CSRF header — allowed
POST /api/bans
Authorization: Bearer <token>
(no X-BanGUI-Request header needed)
# ✓ Safe GET method without CSRF header — allowed
GET /api/jails
Cookie: bangui_session=...
(no X-BanGUI-Request header needed)
```
### fail2ban_start_command Configuration
The `fail2ban_start_command` setting specifies the shell command used to start the fail2ban daemon during recovery operations (e.g., after a rollback).

View File

@@ -1,67 +1,3 @@
## TASK-022 — Session tokens stored in plaintext in SQLite
**Severity:** High
### Where found
`backend/app/db.py``sessions` table schema: `token TEXT NOT NULL UNIQUE`. `backend/app/repositories/session_repo.py``INSERT INTO sessions (token, ...)` and `SELECT ... WHERE token = ?` both use the raw token value.
### Why this is needed
If the BanGUI SQLite database file is exposed (volume mount misconfiguration, backup leak, path traversal via another vulnerability), all active session tokens are immediately usable — no cracking required. The attacker can directly use the token in the `bangui_session` cookie or `Authorization: Bearer` header.
### Goal
Store a one-way hash of the session token in the database so that the DB file alone is not sufficient to hijack a session.
### What to do
1. In `session_repo.create_session()`, store `hashlib.sha256(token.encode()).hexdigest()` instead of `token` in the `token` column.
2. In `session_repo.get_session()` and `delete_session()`, hash the supplied token before the SQL lookup.
3. The `Session` model's `token` field returned to the service layer still contains the raw token (for use in signing and response) — only the DB column changes.
4. Add a migration (`_MIGRATIONS[2]`) that renames the existing `sessions` table to `sessions_old`, creates a new one, and drops `sessions_old` (or simply truncates all sessions on upgrade, since they are all compromised anyway once the DB was readable in plaintext).
### Possible traps and issues
- Coordinate with TASK-025 (HMAC bypass) — both fixes invalidate all existing sessions. Do them in the same release.
- The migration must be atomic (see TASK-023).
- The `Session.token` field name is slightly misleading once it stores a hash — consider renaming the DB column to `token_hash`.
### Docs changes needed
- `Architekture.md` — update session data model description.
- `Backend-Development.md` — document the session token hashing pattern.
### Doc references
- [Architekture.md](Architekture.md) — authentication and session model
- [Backend-Development.md](Backend-Development.md) — security patterns
---
## TASK-023 — Database migration is non-atomic
**Severity:** Medium
### Where found
`backend/app/db.py``_apply_migration()`: calls `db.executescript(migration_script)` (which auto-commits per SQLite Python driver behavior) and then separately `db.execute("INSERT INTO schema_migrations ...")` + `db.commit()`.
### Why this is needed
`executescript()` issues an implicit `COMMIT` before executing the script, so the schema change and the migration record insertion are in two separate transactions. A process crash between them leaves the database in a migrated-but-unrecorded state. On next startup, the migration is re-applied. For a migration that is not idempotent (e.g., `INSERT` without `OR IGNORE`, `ALTER TABLE ADD COLUMN` without `IF NOT EXISTS`), this causes a runtime error or data duplication.
### Goal
Wrap each migration's DDL and its `schema_migrations` record in a single atomic transaction.
### What to do
1. Replace `db.executescript(migration_script)` with individual `await db.execute(stmt)` calls for each DDL statement in the migration (split on `;`).
2. Wrap the entire migration (all DDL statements + the `INSERT INTO schema_migrations`) in an explicit `BEGIN IMMEDIATE` ... `COMMIT` transaction.
3. Test: verify that a simulated crash mid-migration (mocked `execute` that raises on the second statement) leaves the DB at its prior version.
### Possible traps and issues
- SQLite DDL in WAL mode: `CREATE TABLE IF NOT EXISTS` and `CREATE INDEX IF NOT EXISTS` are safe to re-run. `ALTER TABLE ADD COLUMN` is not — it must be guarded with a `PRAGMA table_info` check if used in future migrations.
- Splitting a migration script on `;` must handle semicolons inside string literals and comments. Consider storing each migration as a `list[str]` of individual statements instead of a single script string.
### Docs changes needed
- `Backend-Development.md` — migration authoring guidelines.
### Doc references
- [Backend-Development.md](Backend-Development.md) — database schema and migrations
---
## TASK-024 — No CSRF protection on state-mutating endpoints
**Severity:** High

View File

@@ -114,6 +114,19 @@ fetchBans(24, ctrl.signal) // Pass the signal to enable cancellation on unmount
.catch(err => { /* ... */ });
```
### CSRF Protection Header
All state-mutating requests (POST, PUT, DELETE, PATCH) automatically include the custom header `X-BanGUI-Request: 1` via the central API client. This protects against Cross-Site Request Forgery (CSRF) attacks by requiring a custom header that cross-site JavaScript cannot set without CORS preflight.
**How it works:**
- The `request()` function in `api/client.ts` includes `"X-BanGUI-Request": "1"` in the default headers.
- GET, HEAD, and OPTIONS requests are unaffected.
- Bearer token authentication bypasses the check (tokens are not CSRF-vulnerable).
- The backend `CsrfMiddleware` validates this header for cookie-authenticated state-mutating requests.
- Requests missing the header receive a `403 Forbidden` response.
**No Action Required:** As a developer, you do not need to manually add this header — the centralized API client handles it automatically. All `api.post()`, `api.put()`, `api.del()` calls will include it.
### Request Deduplication & Shared Caching
When multiple components mount simultaneously and need the same data, **implement shared hooks with request deduplication** to avoid duplicate API calls. Use a module-level cache to ensure all consumers share a single in-flight request:

View File

@@ -57,6 +57,7 @@ from app.exceptions import (
JailOperationError,
ServerOperationError,
)
from app.middleware.csrf import CsrfMiddleware
from app.routers import (
auth,
bans,
@@ -506,8 +507,11 @@ def create_app(settings: Settings | None = None) -> FastAPI:
# --- Middleware ---
# Note: middleware is applied in reverse order of registration.
# The setup-redirect must run *after* CORS, so it is added last.
# The setup-redirect must run *after* CSRF, so it is added last.
# CSRF middleware protects cookie-authenticated state-mutating requests.
app.add_middleware(SetupRedirectMiddleware)
app.add_middleware(CsrfMiddleware)
# --- Exception handlers ---
# Ordered from most specific to least specific. FastAPI evaluates handlers

View File

@@ -0,0 +1,3 @@
"""Application middleware."""
from __future__ import annotations

View File

@@ -0,0 +1,94 @@
"""CSRF protection middleware for cookie-authenticated state-mutating requests.
This middleware enforces explicit CSRF protection on POST, PUT, DELETE, and PATCH
requests that use cookie-based authentication. Requests must include the custom
header `X-BanGUI-Request: 1` to proceed.
Bearer token authentication (via Authorization header) bypasses this check as it
is not CSRF-vulnerable. GET, HEAD, and OPTIONS requests are also exempt.
Cross-site requests cannot set custom headers without CORS preflight, which the
backend rejects for non-allowed origins, providing defense-in-depth.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
import structlog
from fastapi import status
from fastapi.responses import JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware
if TYPE_CHECKING:
from collections.abc import Awaitable, Callable
from starlette.requests import Request
from starlette.responses import Response as StarletteResponse
log: structlog.stdlib.BoundLogger = structlog.get_logger()
# Header name and value that clients must provide for state-mutating requests.
_CSRF_HEADER_NAME: str = "X-BanGUI-Request"
_CSRF_HEADER_VALUE: str = "1"
# HTTP methods that require CSRF protection.
_CSRF_PROTECTED_METHODS: frozenset[str] = frozenset({"POST", "PUT", "DELETE", "PATCH"})
# Session cookie name for detecting cookie-based authentication.
_SESSION_COOKIE_NAME: str = "bangui_session"
class CsrfMiddleware(BaseHTTPMiddleware):
"""Protect cookie-authenticated state-mutating requests with custom header check.
For requests using POST, PUT, DELETE, or PATCH methods that are authenticated
via the session cookie (not Bearer token), this middleware requires the presence
of a custom header to prevent CSRF attacks. Bearer token requests and safe
HTTP methods are exempt.
"""
async def dispatch(
self,
request: Request,
call_next: Callable[[Request], Awaitable[StarletteResponse]],
) -> StarletteResponse:
"""Intercept requests to enforce CSRF protection.
Args:
request: The incoming HTTP request.
call_next: The next middleware / router handler.
Returns:
Either a 403 Forbidden response if CSRF validation fails, or the
normal router response.
"""
# Skip check for safe methods.
if request.method not in _CSRF_PROTECTED_METHODS:
return await call_next(request)
# Skip check if using Bearer token authentication (not CSRF-vulnerable).
auth_header: str = request.headers.get("Authorization", "")
if auth_header.startswith("Bearer "):
return await call_next(request)
# Skip check if not using cookie-based authentication.
if _SESSION_COOKIE_NAME not in request.cookies:
return await call_next(request)
# Enforce CSRF header for cookie-authenticated state-mutating requests.
csrf_header: str | None = request.headers.get(_CSRF_HEADER_NAME)
if csrf_header != _CSRF_HEADER_VALUE:
log.warning(
"csrf_validation_failed",
method=request.method,
path=request.url.path,
has_cookie=True,
csrf_header_present=csrf_header is not None,
)
return JSONResponse(
status_code=status.HTTP_403_FORBIDDEN,
content={"detail": "CSRF validation failed. Request rejected."},
)
return await call_next(request)

View File

@@ -0,0 +1,296 @@
"""Tests for CSRF protection middleware.
The CsrfMiddleware enforces custom header validation for cookie-authenticated
state-mutating requests (POST, PUT, DELETE, PATCH) to prevent cross-site
request forgery attacks.
"""
from __future__ import annotations
from httpx import AsyncClient
from app.utils.constants import SESSION_COOKIE_NAME
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
_SETUP_PAYLOAD = {
"master_password": "Mysecretpass1!",
"database_path": "bangui.db",
"fail2ban_socket": "/var/run/fail2ban/fail2ban.sock",
"timezone": "UTC",
"session_duration_minutes": 60,
}
async def _do_setup(client: AsyncClient) -> None:
"""Run the setup wizard so auth endpoints are reachable."""
resp = await client.post("/api/setup", json=_SETUP_PAYLOAD)
assert resp.status_code == 201
async def _login(client: AsyncClient, password: str = "Mysecretpass1!") -> str:
"""Helper: perform login and return the session token."""
resp = await client.post(
"/api/auth/login",
json={"password": password},
headers={"X-BanGUI-Request": "1"},
)
assert resp.status_code == 200
return str(resp.json()["token"])
# ---------------------------------------------------------------------------
# CSRF Header Validation
# ---------------------------------------------------------------------------
class TestCsrfProtection:
"""CSRF middleware validation tests."""
async def test_post_with_cookie_and_csrf_header_passes(
self, client: AsyncClient
) -> None:
"""POST with session cookie and CSRF header is allowed."""
await _do_setup(client)
token = await _login(client)
# POST with correct CSRF header should succeed (endpoint may fail for other reasons)
response = await client.post(
"/api/auth/logout",
cookies={SESSION_COOKIE_NAME: token},
headers={"X-BanGUI-Request": "1"},
)
# Expect 200 (logout succeeds) not 403 (CSRF failed)
assert response.status_code == 200
async def test_post_with_cookie_without_csrf_header_rejected(
self, client: AsyncClient
) -> None:
"""POST with session cookie but no CSRF header is rejected with 403."""
await _do_setup(client)
token = await _login(client)
# POST without CSRF header should be rejected
response = await client.post(
"/api/auth/logout",
cookies={SESSION_COOKIE_NAME: token},
headers={}, # Explicitly omit X-BanGUI-Request
)
assert response.status_code == 403
body = response.json()
assert "detail" in body
assert "CSRF" in body["detail"]
async def test_post_with_cookie_with_wrong_csrf_value_rejected(
self, client: AsyncClient
) -> None:
"""POST with session cookie and wrong CSRF header value is rejected."""
await _do_setup(client)
token = await _login(client)
# POST with wrong CSRF header value should be rejected
response = await client.post(
"/api/auth/logout",
cookies={SESSION_COOKIE_NAME: token},
headers={"X-BanGUI-Request": "invalid"},
)
assert response.status_code == 403
async def test_post_with_bearer_token_no_csrf_header_passes(
self, client: AsyncClient
) -> None:
"""POST with Bearer token but no CSRF header is allowed (not CSRF-vulnerable)."""
await _do_setup(client)
token = await _login(client)
# POST with Bearer token but no CSRF header should succeed
response = await client.post(
"/api/auth/logout",
headers={"Authorization": f"Bearer {token}"},
)
# Expect 200 (logout succeeds) not 403 (CSRF check should be skipped)
assert response.status_code == 200
async def test_get_with_cookie_no_csrf_header_passes(
self, client: AsyncClient
) -> None:
"""GET with session cookie but no CSRF header is allowed (safe method)."""
await _do_setup(client)
token = await _login(client)
# GET without CSRF header should succeed (safe method)
response = await client.get(
"/api/auth/session",
cookies={SESSION_COOKIE_NAME: token},
headers={}, # Explicitly omit X-BanGUI-Request
)
# Expect 200 (session valid) not 403 (CSRF check should be skipped for GET)
assert response.status_code == 200
async def test_options_with_cookie_no_csrf_header_passes(
self, client: AsyncClient
) -> None:
"""OPTIONS with session cookie but no CSRF header is allowed (safe method)."""
await _do_setup(client)
token = await _login(client)
# OPTIONS without CSRF header should succeed (safe method)
response = await client.options(
"/api/auth/session",
cookies={SESSION_COOKIE_NAME: token},
headers={},
)
# Expect not 403
assert response.status_code != 403
async def test_head_with_cookie_no_csrf_header_passes(
self, client: AsyncClient
) -> None:
"""HEAD with session cookie but no CSRF header is allowed (safe method)."""
await _do_setup(client)
token = await _login(client)
# HEAD without CSRF header should succeed (safe method)
response = await client.head(
"/api/auth/session",
cookies={SESSION_COOKIE_NAME: token},
headers={},
)
# Expect not 403
assert response.status_code != 403
async def test_delete_with_cookie_and_csrf_header_passes(
self, client: AsyncClient
) -> None:
"""DELETE with session cookie and CSRF header is allowed."""
await _do_setup(client)
token = await _login(client)
# DELETE with correct CSRF header should not be rejected by CSRF middleware
# The endpoint may fail for other reasons (no ban to delete), but not 403 CSRF
response = await client.request(
"DELETE",
"/api/bans",
content='{"ip": "192.0.2.1", "jail": "sshd"}',
cookies={SESSION_COOKIE_NAME: token},
headers={"X-BanGUI-Request": "1"},
)
# Should not be 403 (CSRF failed)
assert response.status_code != 403
async def test_delete_with_cookie_without_csrf_header_rejected(
self, client: AsyncClient
) -> None:
"""DELETE with session cookie but no CSRF header is rejected with 403."""
await _do_setup(client)
token = await _login(client)
# DELETE without CSRF header should be rejected
response = await client.request(
"DELETE",
"/api/bans",
content='{"ip": "192.0.2.1", "jail": "sshd"}',
cookies={SESSION_COOKIE_NAME: token},
headers={},
)
assert response.status_code == 403
async def test_put_with_cookie_and_csrf_header_passes(
self, client: AsyncClient
) -> None:
"""PUT with session cookie and CSRF header is allowed."""
await _do_setup(client)
token = await _login(client)
# PUT with correct CSRF header should not be rejected by CSRF middleware
response = await client.put(
"/api/blocklists/schedule",
json={"enabled": False},
cookies={SESSION_COOKIE_NAME: token},
headers={"X-BanGUI-Request": "1"},
)
# Should not be 403 (CSRF failed)
assert response.status_code != 403
async def test_put_with_cookie_without_csrf_header_rejected(
self, client: AsyncClient
) -> None:
"""PUT with session cookie but no CSRF header is rejected with 403."""
await _do_setup(client)
token = await _login(client)
# PUT without CSRF header should be rejected
response = await client.put(
"/api/blocklists/schedule",
json={"enabled": False},
cookies={SESSION_COOKIE_NAME: token},
headers={},
)
assert response.status_code == 403
async def test_patch_with_cookie_and_csrf_header_passes(
self, client: AsyncClient
) -> None:
"""PATCH with session cookie and CSRF header is allowed."""
await _do_setup(client)
token = await _login(client)
# PATCH with correct CSRF header should not be rejected by CSRF middleware
# (endpoint may not exist, but CSRF check should pass)
response = await client.patch(
"/api/auth/logout",
cookies={SESSION_COOKIE_NAME: token},
headers={"X-BanGUI-Request": "1"},
)
# Should not be 403 (CSRF failed)
assert response.status_code != 403
async def test_patch_with_cookie_without_csrf_header_rejected(
self, client: AsyncClient
) -> None:
"""PATCH with session cookie but no CSRF header is rejected with 403."""
await _do_setup(client)
token = await _login(client)
# PATCH without CSRF header should be rejected
response = await client.patch(
"/api/auth/logout",
cookies={SESSION_COOKIE_NAME: token},
headers={},
)
assert response.status_code == 403
async def test_post_without_cookie_no_csrf_header_passes(
self, client: AsyncClient
) -> None:
"""POST without session cookie or Bearer token bypasses CSRF check."""
await _do_setup(client)
# POST without any authentication should bypass CSRF check
# (the endpoint itself will reject it with 401, not 403)
response = await client.post(
"/api/auth/logout",
headers={},
)
# Should be 401 (auth required) not 403 (CSRF failed)
# The endpoint may fail differently, but not with CSRF error
# (Actually logout is idempotent and doesn't require auth, so we expect 200)
assert response.status_code in (200, 401)
async def test_bearer_token_via_authorization_header(
self, client: AsyncClient
) -> None:
"""Bearer token in Authorization header bypasses CSRF check."""
await _do_setup(client)
token = await _login(client)
# POST with Bearer token via Authorization header and no CSRF header
# should NOT be rejected by CSRF middleware
response = await client.post(
"/api/auth/logout",
headers={"Authorization": f"Bearer {token}"},
)
# Should succeed (200) not fail with 403
assert response.status_code == 200

View File

@@ -86,6 +86,7 @@ async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
credentials: "include",
headers: {
"Content-Type": "application/json",
"X-BanGUI-Request": "1",
...(options.headers as Record<string, string> | undefined),
},
});