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:
@@ -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
|
||||
|
||||
3
backend/app/middleware/__init__.py
Normal file
3
backend/app/middleware/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""Application middleware."""
|
||||
|
||||
from __future__ import annotations
|
||||
94
backend/app/middleware/csrf.py
Normal file
94
backend/app/middleware/csrf.py
Normal 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)
|
||||
296
backend/tests/test_routers/test_csrf.py
Normal file
296
backend/tests/test_routers/test_csrf.py
Normal 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
|
||||
Reference in New Issue
Block a user