Refactor backend architecture and update documentation

- Add CSRF protection middleware implementation
- Update API client with improved configuration
- Enhance documentation for backend development
- Add architecture documentation updates
- Reorganize and clean up task documentation

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-04-26 14:52:23 +02:00
parent a44f1ef35b
commit c2348d7075
9 changed files with 470 additions and 66 deletions

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