diff --git a/Docs/Security.md b/Docs/Security.md index d887f26..2cda7d6 100644 --- a/Docs/Security.md +++ b/Docs/Security.md @@ -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 See `backend/app/middleware/csrf.py` and `backend/app/middleware/rate_limit.py` for CSRF protection and rate limiting. diff --git a/backend/app/middleware/csrf.py b/backend/app/middleware/csrf.py index 27250d2..300a563 100644 --- a/backend/app/middleware/csrf.py +++ b/backend/app/middleware/csrf.py @@ -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, diff --git a/backend/app/models/config.py b/backend/app/models/config.py index 3e4329b..3fe148f 100644 --- a/backend/app/models/config.py +++ b/backend/app/models/config.py @@ -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.", + ) diff --git a/backend/app/routers/config_misc.py b/backend/app/routers/config_misc.py index a52420b..ef6aba5 100644 --- a/backend/app/routers/config_misc.py +++ b/backend/app/routers/config_misc.py @@ -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, + ) diff --git a/backend/app/utils/constants.py b/backend/app/utils/constants.py index ab7728f..a6366f2 100644 --- a/backend/app/utils/constants.py +++ b/backend/app/utils/constants.py @@ -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) # --------------------------------------------------------------------------- diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 1ca5efd..b16b70e 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -16,6 +16,7 @@ import { ErrorResponse } from "../types/response"; 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. */ const BASE_URL: string = import.meta.env.VITE_API_URL ?? "/api/v1"; @@ -173,7 +174,7 @@ async function request(url: string, options: RequestInit = {}): Promise { // Only set CSRF header for state-mutating requests (not for GET/HEAD/OPTIONS). if (isMutatingMethod) { - headers["X-BanGUI-Request"] = "1"; + headers[CSRF_HEADER_NAME] = CSRF_HEADER_VALUE; } // Always add correlation ID for distributed tracing diff --git a/frontend/src/utils/constants.ts b/frontend/src/utils/constants.ts index ba1ad31..ffb94e0 100644 --- a/frontend/src/utils/constants.ts +++ b/frontend/src/utils/constants.ts @@ -38,3 +38,13 @@ export const STORAGE_KEY_SIDEBAR_COLLAPSED = "bangui_sidebar_collapsed" as const /** LocalStorage key for theme preference. */ 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;