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:
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user