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:
@@ -20,7 +20,7 @@ from fastapi import status
|
||||
from fastapi.responses import JSONResponse
|
||||
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:
|
||||
from collections.abc import Awaitable, Callable
|
||||
@@ -30,10 +30,6 @@ if TYPE_CHECKING:
|
||||
|
||||
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"})
|
||||
|
||||
@@ -76,8 +72,8 @@ class CsrfMiddleware(BaseHTTPMiddleware):
|
||||
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:
|
||||
csrf_header: str | None = request.headers.get(CSRF_HEADER_NAME)
|
||||
if csrf_header != CSRF_HEADER_VALUE:
|
||||
log.warning(
|
||||
"csrf_validation_failed",
|
||||
method=request.method,
|
||||
|
||||
@@ -884,3 +884,21 @@ class ServiceStatusResponse(BanGuiBaseModel):
|
||||
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_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.",
|
||||
)
|
||||
|
||||
@@ -16,6 +16,7 @@ from app.dependencies import (
|
||||
SettingsServiceContextDep,
|
||||
)
|
||||
from app.exceptions import OperationError
|
||||
from app.mappers import config_mappers
|
||||
from app.models.config import (
|
||||
Fail2BanLogResponse,
|
||||
GlobalConfigResponse,
|
||||
@@ -26,15 +27,15 @@ from app.models.config import (
|
||||
MapColorThresholdsUpdate,
|
||||
RegexTestRequest,
|
||||
RegexTestResponse,
|
||||
SecurityHeadersResponse,
|
||||
ServiceStatusResponse,
|
||||
)
|
||||
from app.mappers import config_mappers
|
||||
from app.services import (
|
||||
config_service,
|
||||
jail_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()
|
||||
|
||||
@@ -59,9 +60,10 @@ def _check_config_update_rate_limit(
|
||||
_CONFIG_UPDATE_BUCKET, client_ip, RATE_LIMIT_CONFIG_UPDATE_REQUESTS, _MINUTE
|
||||
)
|
||||
if not is_allowed:
|
||||
from app.exceptions import RateLimitError
|
||||
import structlog
|
||||
|
||||
from app.exceptions import RateLimitError
|
||||
|
||||
log = structlog.get_logger()
|
||||
log.warning(
|
||||
"config_update_rate_limit_exceeded",
|
||||
@@ -501,3 +503,37 @@ async def get_service_status(
|
||||
probe_fn=health_service.probe,
|
||||
)
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -45,6 +45,12 @@ SESSION_TOKEN_SIGNATURE_SEPARATOR: Final[str] = "."
|
||||
SESSION_COOKIE_NAME: Final[str] = "bangui_session"
|
||||
"""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)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user