feat(security): add CSRF header constants and security-headers endpoint

Move X-BanGUI-Request header name/value to backend/app/utils/constants.py as single source of truth. Add GET /api/v1/config/security-headers endpoint. Update csrf middleware, frontend api client, and docs to use shared constants.
This commit is contained in:
2026-05-03 22:06:43 +02:00
parent cee3daffc1
commit dafe8d61e2
7 changed files with 109 additions and 11 deletions

View File

@@ -77,6 +77,37 @@ To verify headers are being sent correctly:
--- ---
## CSRF Protection
BanGUI protects cookie-authenticated state-mutating requests (POST, PUT, DELETE, PATCH) with a custom header check. Requests using the session cookie must include the header `X-BanGUI-Request: 1`. Bearer token authentication is exempt since tokens in headers are not CSRF-vulnerable.
### Single Source of Truth
The header name and value are defined once in `backend/app/utils/constants.py` (`CSRF_HEADER_NAME` and `CSRF_HEADER_VALUE`) and consumed by:
- `backend/app/middleware/csrf.py` — validates the header on incoming requests
- `frontend/src/api/client.ts` — attaches the header to state-mutating fetch calls
- `frontend/src/utils/constants.ts` — mirrors the values for type-safe import
### Endpoint
**`GET /api/v1/config/security-headers`** — returns the CSRF header name and value to authenticated clients:
```json
{
"csrf_header_name": "X-BanGUI-Request",
"csrf_header_value": "1"
}
```
This allows the frontend to discover the required header at runtime. Both frontend and backend constants must remain in sync — a build-time check is recommended when updating either constant.
### Header Rationale
The custom header is required because browsers block cross-site requests from setting custom headers without a CORS preflight, which BanGUI rejects for non-allowed origins.
---
## Session Security ## Session Security
See `backend/app/middleware/csrf.py` and `backend/app/middleware/rate_limit.py` for CSRF protection and rate limiting. See `backend/app/middleware/csrf.py` and `backend/app/middleware/rate_limit.py` for CSRF protection and rate limiting.

View File

@@ -20,7 +20,7 @@ from fastapi import status
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware from starlette.middleware.base import BaseHTTPMiddleware
from app.utils.constants import SESSION_COOKIE_NAME from app.utils.constants import CSRF_HEADER_NAME, CSRF_HEADER_VALUE, SESSION_COOKIE_NAME
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
@@ -30,10 +30,6 @@ if TYPE_CHECKING:
log: structlog.stdlib.BoundLogger = structlog.get_logger() 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. # HTTP methods that require CSRF protection.
_CSRF_PROTECTED_METHODS: frozenset[str] = frozenset({"POST", "PUT", "DELETE", "PATCH"}) _CSRF_PROTECTED_METHODS: frozenset[str] = frozenset({"POST", "PUT", "DELETE", "PATCH"})
@@ -76,8 +72,8 @@ class CsrfMiddleware(BaseHTTPMiddleware):
return await call_next(request) return await call_next(request)
# Enforce CSRF header for cookie-authenticated state-mutating requests. # Enforce CSRF header for cookie-authenticated state-mutating requests.
csrf_header: str | None = request.headers.get(_CSRF_HEADER_NAME) csrf_header: str | None = request.headers.get(CSRF_HEADER_NAME)
if csrf_header != _CSRF_HEADER_VALUE: if csrf_header != CSRF_HEADER_VALUE:
log.warning( log.warning(
"csrf_validation_failed", "csrf_validation_failed",
method=request.method, method=request.method,

View File

@@ -884,3 +884,21 @@ class ServiceStatusResponse(BanGuiBaseModel):
total_failures: int = Field(default=0, ge=0, description="Aggregated current failure count across all jails.") total_failures: int = Field(default=0, ge=0, description="Aggregated current failure count across all jails.")
log_level: str = Field(default="UNKNOWN", description="Current fail2ban log level.") log_level: str = Field(default="UNKNOWN", description="Current fail2ban log level.")
log_target: str = Field(default="UNKNOWN", description="Current fail2ban log target.") log_target: str = Field(default="UNKNOWN", description="Current fail2ban log target.")
# ---------------------------------------------------------------------------
# Security headers
# ---------------------------------------------------------------------------
class SecurityHeadersResponse(BanGuiBaseModel):
"""Security-relevant header names and values used by the frontend."""
csrf_header_name: str = Field(
...,
description="Name of the custom header required for state-mutating requests.",
)
csrf_header_value: str = Field(
...,
description="Required value of the CSRF header to pass validation.",
)

View File

@@ -16,6 +16,7 @@ from app.dependencies import (
SettingsServiceContextDep, SettingsServiceContextDep,
) )
from app.exceptions import OperationError from app.exceptions import OperationError
from app.mappers import config_mappers
from app.models.config import ( from app.models.config import (
Fail2BanLogResponse, Fail2BanLogResponse,
GlobalConfigResponse, GlobalConfigResponse,
@@ -26,15 +27,15 @@ from app.models.config import (
MapColorThresholdsUpdate, MapColorThresholdsUpdate,
RegexTestRequest, RegexTestRequest,
RegexTestResponse, RegexTestResponse,
SecurityHeadersResponse,
ServiceStatusResponse, ServiceStatusResponse,
) )
from app.mappers import config_mappers
from app.services import ( from app.services import (
config_service, config_service,
jail_service, jail_service,
log_service, log_service,
) )
from app.utils.constants import RATE_LIMIT_CONFIG_UPDATE_REQUESTS from app.utils.constants import CSRF_HEADER_NAME, CSRF_HEADER_VALUE, RATE_LIMIT_CONFIG_UPDATE_REQUESTS
log: structlog.stdlib.BoundLogger = structlog.get_logger() log: structlog.stdlib.BoundLogger = structlog.get_logger()
@@ -59,9 +60,10 @@ def _check_config_update_rate_limit(
_CONFIG_UPDATE_BUCKET, client_ip, RATE_LIMIT_CONFIG_UPDATE_REQUESTS, _MINUTE _CONFIG_UPDATE_BUCKET, client_ip, RATE_LIMIT_CONFIG_UPDATE_REQUESTS, _MINUTE
) )
if not is_allowed: if not is_allowed:
from app.exceptions import RateLimitError
import structlog import structlog
from app.exceptions import RateLimitError
log = structlog.get_logger() log = structlog.get_logger()
log.warning( log.warning(
"config_update_rate_limit_exceeded", "config_update_rate_limit_exceeded",
@@ -501,3 +503,37 @@ async def get_service_status(
probe_fn=health_service.probe, probe_fn=health_service.probe,
) )
return config_mappers.map_domain_service_status_to_response(domain_result) return config_mappers.map_domain_service_status_to_response(domain_result)
@router.get(
"/security-headers",
response_model=SecurityHeadersResponse,
summary="Return security-relevant header configuration",
responses={
200: {"description": "Security header names and values returned", "model": SecurityHeadersResponse},
401: {"description": "Session missing, expired, or invalid"},
},
)
async def get_security_headers(
_request: Request,
_auth: AuthDep,
) -> SecurityHeadersResponse:
"""Return the header name and value used for CSRF protection.
This endpoint allows the frontend to discover the required CSRF header
name and value at runtime rather than hard-coding them. The response
is derived from the same constants used by the backend CSRF middleware,
ensuring a single source of truth.
Args:
request: Incoming request.
_auth: Validated session — enforces authentication.
Returns:
:class:`~app.models.config.SecurityHeadersResponse` with
``csrf_header_name`` and ``csrf_header_value``.
"""
return SecurityHeadersResponse(
csrf_header_name=CSRF_HEADER_NAME,
csrf_header_value=CSRF_HEADER_VALUE,
)

View File

@@ -45,6 +45,12 @@ SESSION_TOKEN_SIGNATURE_SEPARATOR: Final[str] = "."
SESSION_COOKIE_NAME: Final[str] = "bangui_session" SESSION_COOKIE_NAME: Final[str] = "bangui_session"
"""Name of the session cookie used by the browser SPA.""" """Name of the session cookie used by the browser SPA."""
CSRF_HEADER_NAME: Final[str] = "X-BanGUI-Request"
"""Name of the custom header clients must send for state-mutating requests."""
CSRF_HEADER_VALUE: Final[str] = "1"
"""Required value of the CSRF header to pass validation."""
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Authentication penalty (brute-force resistance) # Authentication penalty (brute-force resistance)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@@ -16,6 +16,7 @@
import { ErrorResponse } from "../types/response"; import { ErrorResponse } from "../types/response";
import { ENDPOINTS } from "./endpoints"; import { ENDPOINTS } from "./endpoints";
import { CSRF_HEADER_NAME, CSRF_HEADER_VALUE } from "../utils/constants";
/** Base URL for all API calls. Falls back to `/api/v1` in production. */ /** Base URL for all API calls. Falls back to `/api/v1` in production. */
const BASE_URL: string = import.meta.env.VITE_API_URL ?? "/api/v1"; const BASE_URL: string = import.meta.env.VITE_API_URL ?? "/api/v1";
@@ -173,7 +174,7 @@ async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
// Only set CSRF header for state-mutating requests (not for GET/HEAD/OPTIONS). // Only set CSRF header for state-mutating requests (not for GET/HEAD/OPTIONS).
if (isMutatingMethod) { if (isMutatingMethod) {
headers["X-BanGUI-Request"] = "1"; headers[CSRF_HEADER_NAME] = CSRF_HEADER_VALUE;
} }
// Always add correlation ID for distributed tracing // Always add correlation ID for distributed tracing

View File

@@ -38,3 +38,13 @@ export const STORAGE_KEY_SIDEBAR_COLLAPSED = "bangui_sidebar_collapsed" as const
/** LocalStorage key for theme preference. */ /** LocalStorage key for theme preference. */
export const STORAGE_KEY_THEME = "bangui_theme" as const; export const STORAGE_KEY_THEME = "bangui_theme" as const;
// ---------------------------------------------------------------------------
// Security
// ---------------------------------------------------------------------------
/** CSRF header name - must match backend/app/utils/constants.py CSRF_HEADER_NAME. */
export const CSRF_HEADER_NAME = "X-BanGUI-Request" as const;
/** CSRF header required value - must match backend/app/utils/constants.py CSRF_HEADER_VALUE. */
export const CSRF_HEADER_VALUE = "1" as const;