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:
@@ -675,8 +675,21 @@ BanGUI maintains its **own SQLite database** (separate from the fail2ban databas
|
|||||||
- **Runtime state** (`RuntimeState` in `app.utils.runtime_state`) — stores mutable application state: `server_status` (fail2ban online/offline), `last_activation` (jail activation tracking), `pending_recovery` (crash detection), and `runtime_settings` (effective configuration). **⚠️ RuntimeState is process-local and only safe when BanGUI runs as a single asyncio worker.** Mutations must not span `await` points (cooperative scheduling within a single event loop is safe). In multi-worker deployments, each process has its own copy — logouts from worker A don't affect worker B's cache, health status updates are per-worker, and activation tracking is unreliable. BanGUI enforces single-worker mode (TASK-002) to prevent this issue. For future multi-worker support, replace RuntimeState with a shared coordination backend (Redis, shared memory, database). See `app/utils/runtime_state.py` module docstring for details.
|
- **Runtime state** (`RuntimeState` in `app.utils.runtime_state`) — stores mutable application state: `server_status` (fail2ban online/offline), `last_activation` (jail activation tracking), `pending_recovery` (crash detection), and `runtime_settings` (effective configuration). **⚠️ RuntimeState is process-local and only safe when BanGUI runs as a single asyncio worker.** Mutations must not span `await` points (cooperative scheduling within a single event loop is safe). In multi-worker deployments, each process has its own copy — logouts from worker A don't affect worker B's cache, health status updates are per-worker, and activation tracking is unreliable. BanGUI enforces single-worker mode (TASK-002) to prevent this issue. For future multi-worker support, replace RuntimeState with a shared coordination backend (Redis, shared memory, database). See `app/utils/runtime_state.py` module docstring for details.
|
||||||
- **Setup-completion flag** — once `is_setup_complete()` returns `True`, the result is stored in `app.state._setup_complete_cached`. The `SetupRedirectMiddleware` skips the DB query on all subsequent requests, removing 1 SQL query per request for the common post-setup case. The completion flag is only written after the runtime database is successfully initialized and all initial setup settings are persisted, preventing a failed setup from permanently bypassing the setup wizard.
|
- **Setup-completion flag** — once `is_setup_complete()` returns `True`, the result is stored in `app.state._setup_complete_cached`. The `SetupRedirectMiddleware` skips the DB query on all subsequent requests, removing 1 SQL query per request for the common post-setup case. The completion flag is only written after the runtime database is successfully initialized and all initial setup settings are persisted, preventing a failed setup from permanently bypassing the setup wizard.
|
||||||
|
|
||||||
---
|
### 6.1 CSRF Protection
|
||||||
|
|
||||||
|
State-mutating endpoints (POST, PUT, DELETE, PATCH) that use cookie-based authentication are protected against Cross-Site Request Forgery (CSRF) attacks via a **custom header check middleware**.
|
||||||
|
|
||||||
|
**Design:**
|
||||||
|
- For requests authenticated via the session cookie (not Bearer token), the `CsrfMiddleware` requires the custom header `X-BanGUI-Request: 1` to be present.
|
||||||
|
- The frontend API client automatically includes this header on all requests.
|
||||||
|
- Cross-site `fetch()` calls cannot set custom headers without CORS preflight, which the backend rejects for non-allowed origins, providing defense-in-depth.
|
||||||
|
- Safe HTTP methods (GET, HEAD, OPTIONS) bypass the check.
|
||||||
|
- Bearer token authentication (via `Authorization: Bearer` header) bypasses the check because tokens are not CSRF-vulnerable (they are not automatically sent on cross-origin requests).
|
||||||
|
- Requests missing the CSRF header receive a `403 Forbidden` response with detail: `"CSRF validation failed. Request rejected."`.
|
||||||
|
|
||||||
|
This mechanism complements the existing `SameSite=Lax` cookie policy, which blocks traditional `<form>` POST requests but does not protect against JavaScript-initiated requests on a subdomain or same-origin XSS injection.
|
||||||
|
|
||||||
|
---
|
||||||
## 7. Scheduling
|
## 7. Scheduling
|
||||||
|
|
||||||
APScheduler 4.x (async mode) manages recurring background tasks.
|
APScheduler 4.x (async mode) manages recurring background tasks.
|
||||||
|
|||||||
@@ -768,6 +768,50 @@ environment:
|
|||||||
|
|
||||||
**Important:** If `Secure=true` is set, browsers will reject the session cookie when the backend is served over HTTP. Ensure your nginx/reverse proxy terminates TLS and passes `X-Forwarded-Proto: https` so FastAPI knows the connection is secure.
|
**Important:** If `Secure=true` is set, browsers will reject the session cookie when the backend is served over HTTP. Ensure your nginx/reverse proxy terminates TLS and passes `X-Forwarded-Proto: https` so FastAPI knows the connection is secure.
|
||||||
|
|
||||||
|
### CSRF Protection Middleware
|
||||||
|
|
||||||
|
State-mutating endpoints (POST, PUT, DELETE, PATCH) authenticated via session cookies are protected by the `CsrfMiddleware`, which enforces a custom header check.
|
||||||
|
|
||||||
|
**How It Works:**
|
||||||
|
|
||||||
|
1. For every request using a mutating HTTP method, the middleware checks:
|
||||||
|
- Is this request authenticated via session cookie (not Bearer token)?
|
||||||
|
- If yes, require the custom header `X-BanGUI-Request: 1`.
|
||||||
|
- If missing or incorrect, return `403 Forbidden`.
|
||||||
|
|
||||||
|
2. **Bearer token requests** (via `Authorization: Bearer` header) bypass the check because tokens are not CSRF-vulnerable — they are never automatically sent on cross-origin requests.
|
||||||
|
|
||||||
|
3. **Safe HTTP methods** (GET, HEAD, OPTIONS) bypass the check.
|
||||||
|
|
||||||
|
4. **Cross-site protection:** Cross-site JavaScript (`fetch()` calls from other origins) cannot set custom headers without CORS preflight, which the backend rejects for non-allowed origins. This provides defense-in-depth against subdomain attacks and XSS injection.
|
||||||
|
|
||||||
|
**Implementation Location:**
|
||||||
|
- Middleware: `backend/app/middleware/csrf.py`
|
||||||
|
- Registered in: `backend/app/main.py` via `app.add_middleware(CsrfMiddleware)`
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```python
|
||||||
|
# ✓ Cookie-authenticated POST with CSRF header — allowed
|
||||||
|
POST /api/bans
|
||||||
|
Cookie: bangui_session=...
|
||||||
|
X-BanGUI-Request: 1
|
||||||
|
|
||||||
|
# ✗ Cookie-authenticated POST without CSRF header — rejected with 403
|
||||||
|
POST /api/bans
|
||||||
|
Cookie: bangui_session=...
|
||||||
|
(no X-BanGUI-Request header)
|
||||||
|
|
||||||
|
# ✓ Bearer token authentication without CSRF header — allowed
|
||||||
|
POST /api/bans
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
(no X-BanGUI-Request header needed)
|
||||||
|
|
||||||
|
# ✓ Safe GET method without CSRF header — allowed
|
||||||
|
GET /api/jails
|
||||||
|
Cookie: bangui_session=...
|
||||||
|
(no X-BanGUI-Request header needed)
|
||||||
|
```
|
||||||
|
|
||||||
### fail2ban_start_command Configuration
|
### fail2ban_start_command Configuration
|
||||||
|
|
||||||
The `fail2ban_start_command` setting specifies the shell command used to start the fail2ban daemon during recovery operations (e.g., after a rollback).
|
The `fail2ban_start_command` setting specifies the shell command used to start the fail2ban daemon during recovery operations (e.g., after a rollback).
|
||||||
|
|||||||
@@ -1,67 +1,3 @@
|
|||||||
## TASK-022 — Session tokens stored in plaintext in SQLite
|
|
||||||
|
|
||||||
**Severity:** High
|
|
||||||
|
|
||||||
### Where found
|
|
||||||
`backend/app/db.py` — `sessions` table schema: `token TEXT NOT NULL UNIQUE`. `backend/app/repositories/session_repo.py` — `INSERT INTO sessions (token, ...)` and `SELECT ... WHERE token = ?` both use the raw token value.
|
|
||||||
|
|
||||||
### Why this is needed
|
|
||||||
If the BanGUI SQLite database file is exposed (volume mount misconfiguration, backup leak, path traversal via another vulnerability), all active session tokens are immediately usable — no cracking required. The attacker can directly use the token in the `bangui_session` cookie or `Authorization: Bearer` header.
|
|
||||||
|
|
||||||
### Goal
|
|
||||||
Store a one-way hash of the session token in the database so that the DB file alone is not sufficient to hijack a session.
|
|
||||||
|
|
||||||
### What to do
|
|
||||||
1. In `session_repo.create_session()`, store `hashlib.sha256(token.encode()).hexdigest()` instead of `token` in the `token` column.
|
|
||||||
2. In `session_repo.get_session()` and `delete_session()`, hash the supplied token before the SQL lookup.
|
|
||||||
3. The `Session` model's `token` field returned to the service layer still contains the raw token (for use in signing and response) — only the DB column changes.
|
|
||||||
4. Add a migration (`_MIGRATIONS[2]`) that renames the existing `sessions` table to `sessions_old`, creates a new one, and drops `sessions_old` (or simply truncates all sessions on upgrade, since they are all compromised anyway once the DB was readable in plaintext).
|
|
||||||
|
|
||||||
### Possible traps and issues
|
|
||||||
- Coordinate with TASK-025 (HMAC bypass) — both fixes invalidate all existing sessions. Do them in the same release.
|
|
||||||
- The migration must be atomic (see TASK-023).
|
|
||||||
- The `Session.token` field name is slightly misleading once it stores a hash — consider renaming the DB column to `token_hash`.
|
|
||||||
|
|
||||||
### Docs changes needed
|
|
||||||
- `Architekture.md` — update session data model description.
|
|
||||||
- `Backend-Development.md` — document the session token hashing pattern.
|
|
||||||
|
|
||||||
### Doc references
|
|
||||||
- [Architekture.md](Architekture.md) — authentication and session model
|
|
||||||
- [Backend-Development.md](Backend-Development.md) — security patterns
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## TASK-023 — Database migration is non-atomic
|
|
||||||
|
|
||||||
**Severity:** Medium
|
|
||||||
|
|
||||||
### Where found
|
|
||||||
`backend/app/db.py` — `_apply_migration()`: calls `db.executescript(migration_script)` (which auto-commits per SQLite Python driver behavior) and then separately `db.execute("INSERT INTO schema_migrations ...")` + `db.commit()`.
|
|
||||||
|
|
||||||
### Why this is needed
|
|
||||||
`executescript()` issues an implicit `COMMIT` before executing the script, so the schema change and the migration record insertion are in two separate transactions. A process crash between them leaves the database in a migrated-but-unrecorded state. On next startup, the migration is re-applied. For a migration that is not idempotent (e.g., `INSERT` without `OR IGNORE`, `ALTER TABLE ADD COLUMN` without `IF NOT EXISTS`), this causes a runtime error or data duplication.
|
|
||||||
|
|
||||||
### Goal
|
|
||||||
Wrap each migration's DDL and its `schema_migrations` record in a single atomic transaction.
|
|
||||||
|
|
||||||
### What to do
|
|
||||||
1. Replace `db.executescript(migration_script)` with individual `await db.execute(stmt)` calls for each DDL statement in the migration (split on `;`).
|
|
||||||
2. Wrap the entire migration (all DDL statements + the `INSERT INTO schema_migrations`) in an explicit `BEGIN IMMEDIATE` ... `COMMIT` transaction.
|
|
||||||
3. Test: verify that a simulated crash mid-migration (mocked `execute` that raises on the second statement) leaves the DB at its prior version.
|
|
||||||
|
|
||||||
### Possible traps and issues
|
|
||||||
- SQLite DDL in WAL mode: `CREATE TABLE IF NOT EXISTS` and `CREATE INDEX IF NOT EXISTS` are safe to re-run. `ALTER TABLE ADD COLUMN` is not — it must be guarded with a `PRAGMA table_info` check if used in future migrations.
|
|
||||||
- Splitting a migration script on `;` must handle semicolons inside string literals and comments. Consider storing each migration as a `list[str]` of individual statements instead of a single script string.
|
|
||||||
|
|
||||||
### Docs changes needed
|
|
||||||
- `Backend-Development.md` — migration authoring guidelines.
|
|
||||||
|
|
||||||
### Doc references
|
|
||||||
- [Backend-Development.md](Backend-Development.md) — database schema and migrations
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## TASK-024 — No CSRF protection on state-mutating endpoints
|
## TASK-024 — No CSRF protection on state-mutating endpoints
|
||||||
|
|
||||||
**Severity:** High
|
**Severity:** High
|
||||||
|
|||||||
@@ -114,6 +114,19 @@ fetchBans(24, ctrl.signal) // Pass the signal to enable cancellation on unmount
|
|||||||
.catch(err => { /* ... */ });
|
.catch(err => { /* ... */ });
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### CSRF Protection Header
|
||||||
|
|
||||||
|
All state-mutating requests (POST, PUT, DELETE, PATCH) automatically include the custom header `X-BanGUI-Request: 1` via the central API client. This protects against Cross-Site Request Forgery (CSRF) attacks by requiring a custom header that cross-site JavaScript cannot set without CORS preflight.
|
||||||
|
|
||||||
|
**How it works:**
|
||||||
|
- The `request()` function in `api/client.ts` includes `"X-BanGUI-Request": "1"` in the default headers.
|
||||||
|
- GET, HEAD, and OPTIONS requests are unaffected.
|
||||||
|
- Bearer token authentication bypasses the check (tokens are not CSRF-vulnerable).
|
||||||
|
- The backend `CsrfMiddleware` validates this header for cookie-authenticated state-mutating requests.
|
||||||
|
- Requests missing the header receive a `403 Forbidden` response.
|
||||||
|
|
||||||
|
**No Action Required:** As a developer, you do not need to manually add this header — the centralized API client handles it automatically. All `api.post()`, `api.put()`, `api.del()` calls will include it.
|
||||||
|
|
||||||
### Request Deduplication & Shared Caching
|
### Request Deduplication & Shared Caching
|
||||||
|
|
||||||
When multiple components mount simultaneously and need the same data, **implement shared hooks with request deduplication** to avoid duplicate API calls. Use a module-level cache to ensure all consumers share a single in-flight request:
|
When multiple components mount simultaneously and need the same data, **implement shared hooks with request deduplication** to avoid duplicate API calls. Use a module-level cache to ensure all consumers share a single in-flight request:
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ from app.exceptions import (
|
|||||||
JailOperationError,
|
JailOperationError,
|
||||||
ServerOperationError,
|
ServerOperationError,
|
||||||
)
|
)
|
||||||
|
from app.middleware.csrf import CsrfMiddleware
|
||||||
from app.routers import (
|
from app.routers import (
|
||||||
auth,
|
auth,
|
||||||
bans,
|
bans,
|
||||||
@@ -506,8 +507,11 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|||||||
|
|
||||||
# --- Middleware ---
|
# --- Middleware ---
|
||||||
# Note: middleware is applied in reverse order of registration.
|
# 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(SetupRedirectMiddleware)
|
||||||
|
app.add_middleware(CsrfMiddleware)
|
||||||
|
|
||||||
|
|
||||||
# --- Exception handlers ---
|
# --- Exception handlers ---
|
||||||
# Ordered from most specific to least specific. FastAPI evaluates 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
|
||||||
@@ -86,6 +86,7 @@ async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
|
|||||||
credentials: "include",
|
credentials: "include",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
|
"X-BanGUI-Request": "1",
|
||||||
...(options.headers as Record<string, string> | undefined),
|
...(options.headers as Record<string, string> | undefined),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user