feat(auth): implement CSRF token protection for state-changing operations
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
@@ -80,6 +80,7 @@ async def login(request: Request):
|
||||
"show_oauth": OAUTH_CONFIGURED,
|
||||
"oauth_provider_name": OAUTH_PROVIDER_NAME,
|
||||
"app_version": settings.version, # Changed from app_version to version
|
||||
"csrf_token": getattr(request.state, "csrf_token", ""),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ from app.auth import router as auth_router
|
||||
from app.config import settings
|
||||
from app.database import init_db
|
||||
from app.middleware.audit_log import AuditLogMiddleware
|
||||
from app.middleware.csrf import CSRFMiddleware
|
||||
from app.middleware.rate_limit import create_limiter, get_rate_limit_exceeded_handler
|
||||
from app.middleware.request_size_limit import RequestSizeLimitMiddleware
|
||||
from app.middleware.security_headers import SecurityHeadersMiddleware
|
||||
@@ -123,6 +124,11 @@ app.add_middleware(SecurityHeadersMiddleware, config=settings)
|
||||
# See SECURITY_AUDIT.md – Code Security section
|
||||
app.add_middleware(RequestSizeLimitMiddleware, config=settings)
|
||||
|
||||
# 3) CSRF Protection Middleware - validates CSRF tokens for state-changing operations
|
||||
# Only active when AUTH_ENABLED=True. Exempts OAuth callback endpoints.
|
||||
# Tokens are stored in the session and validated via X-CSRF-Token header or form field.
|
||||
app.add_middleware(CSRFMiddleware, config=settings)
|
||||
|
||||
# 2) Audit Logging Middleware - logs all requests with sensitive data masking
|
||||
# Configure via AUDIT_LOGGING_ENABLED environment variable
|
||||
# See SECURITY_AUDIT.md – Infrastructure Security section
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
"""
|
||||
CSRF Protection Middleware for DocuElevate.
|
||||
|
||||
This middleware implements Cross-Site Request Forgery (CSRF) protection for all
|
||||
state-changing HTTP operations (POST, PUT, DELETE, PATCH).
|
||||
|
||||
How it works:
|
||||
- A cryptographically secure token is generated per session and stored in the session.
|
||||
- The token is attached to ``request.state.csrf_token`` so Jinja2 templates can render it.
|
||||
- For every state-changing request the middleware validates the submitted token by
|
||||
checking (in order):
|
||||
1. The ``X-CSRF-Token`` HTTP request header (used by AJAX / fetch calls).
|
||||
2. The ``csrf_token`` field in ``application/x-www-form-urlencoded`` bodies
|
||||
(used by traditional HTML forms such as the login form).
|
||||
Multipart file-upload requests must always supply the token via the header.
|
||||
- Validation is only enforced when ``AUTH_ENABLED=True``. When authentication is
|
||||
disabled (development / single-user mode) the middleware is a no-op.
|
||||
|
||||
Exempt paths (CSRF is not checked even for state-changing methods):
|
||||
- ``/oauth-callback`` – OAuth 2.0 callback; protected by the ``state`` parameter.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
from typing import Callable
|
||||
|
||||
from fastapi import Request, Response
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.responses import JSONResponse, RedirectResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# HTTP methods that change server state and therefore require a valid CSRF token.
|
||||
CSRF_PROTECTED_METHODS = {"POST", "PUT", "DELETE", "PATCH"}
|
||||
|
||||
# Paths that must never be CSRF-checked (e.g. OAuth flow endpoints that carry
|
||||
# their own replay-protection mechanism).
|
||||
CSRF_EXEMPT_PATHS = {
|
||||
"/oauth-callback",
|
||||
}
|
||||
|
||||
|
||||
class CSRFMiddleware(BaseHTTPMiddleware):
|
||||
"""
|
||||
Middleware that generates and validates CSRF tokens for state-changing requests.
|
||||
|
||||
Token lifecycle
|
||||
---------------
|
||||
1. On the first request for a session a 64-character hex token is created with
|
||||
``secrets.token_hex(32)`` and stored in ``request.session["csrf_token"]``.
|
||||
2. On every subsequent request the existing token is read from the session.
|
||||
3. The token is always attached to ``request.state.csrf_token`` so that
|
||||
Jinja2 templates (and response processors) can embed it.
|
||||
|
||||
Validation
|
||||
----------
|
||||
For ``POST``, ``PUT``, ``DELETE``, and ``PATCH`` requests the middleware
|
||||
checks whether the submitted token matches the session token using a
|
||||
constant-time comparison (``secrets.compare_digest``) to prevent timing
|
||||
attacks.
|
||||
|
||||
Failure response
|
||||
----------------
|
||||
- API routes (``/api/*``): HTTP 403 JSON response.
|
||||
- All other routes: HTTP 302 redirect to ``/login?error=…``.
|
||||
"""
|
||||
|
||||
def __init__(self, app, config):
|
||||
"""
|
||||
Initialise the middleware.
|
||||
|
||||
Args:
|
||||
app: The ASGI application.
|
||||
config: Application settings object (``app.config.Settings``).
|
||||
``config.auth_enabled`` controls whether CSRF enforcement is active.
|
||||
"""
|
||||
super().__init__(app)
|
||||
self.config = config
|
||||
self.enabled = config.auth_enabled
|
||||
if self.enabled:
|
||||
logger.info("CSRF protection middleware enabled")
|
||||
else:
|
||||
logger.info("CSRF protection middleware disabled (AUTH_ENABLED=False)")
|
||||
|
||||
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
||||
"""
|
||||
Process the request: generate/attach the token and validate it when required.
|
||||
|
||||
Args:
|
||||
request: Incoming HTTP request.
|
||||
call_next: Next middleware or route handler in the ASGI chain.
|
||||
|
||||
Returns:
|
||||
HTTP response, or an error response when CSRF validation fails.
|
||||
"""
|
||||
if not self.enabled:
|
||||
return await call_next(request)
|
||||
|
||||
# Generate or retrieve the per-session CSRF token.
|
||||
csrf_token = request.session.get("csrf_token")
|
||||
if not csrf_token:
|
||||
csrf_token = secrets.token_hex(32)
|
||||
request.session["csrf_token"] = csrf_token
|
||||
|
||||
# Attach token to request state so templates and route handlers can access it.
|
||||
request.state.csrf_token = csrf_token
|
||||
|
||||
# Validate for state-changing methods on non-exempt paths.
|
||||
if request.method in CSRF_PROTECTED_METHODS and request.url.path not in CSRF_EXEMPT_PATHS:
|
||||
submitted_token = await self._get_submitted_token(request)
|
||||
if not submitted_token or not secrets.compare_digest(csrf_token, submitted_token):
|
||||
logger.warning(
|
||||
f"[SECURITY] CSRF_VALIDATION_FAILED method={request.method} path={request.url.path}"
|
||||
)
|
||||
if request.url.path.startswith("/api/"):
|
||||
return JSONResponse(
|
||||
status_code=403,
|
||||
content={"detail": "CSRF token missing or invalid"},
|
||||
)
|
||||
return RedirectResponse(url="/login?error=Invalid+request", status_code=302)
|
||||
|
||||
return await call_next(request)
|
||||
|
||||
@staticmethod
|
||||
async def _get_submitted_token(request: Request) -> str | None:
|
||||
"""
|
||||
Extract the CSRF token submitted by the client.
|
||||
|
||||
Checks (in priority order):
|
||||
1. ``X-CSRF-Token`` request header – preferred for AJAX / fetch requests.
|
||||
2. ``csrf_token`` form field in ``application/x-www-form-urlencoded`` bodies –
|
||||
used by plain HTML forms (e.g. the login form).
|
||||
|
||||
Multipart bodies (file uploads) are intentionally not parsed here to avoid
|
||||
buffering large uploads in middleware; those endpoints must send the token
|
||||
via the header instead.
|
||||
|
||||
Args:
|
||||
request: The incoming HTTP request.
|
||||
|
||||
Returns:
|
||||
The submitted CSRF token string, or ``None`` if not found.
|
||||
"""
|
||||
# 1. Check the request header (AJAX / fetch).
|
||||
token = request.headers.get("X-CSRF-Token")
|
||||
if token:
|
||||
return token
|
||||
|
||||
# 2. For URL-encoded form bodies only (plain HTML form submissions).
|
||||
content_type = request.headers.get("content-type", "")
|
||||
if "application/x-www-form-urlencoded" in content_type:
|
||||
try:
|
||||
form = await request.form()
|
||||
token = form.get("csrf_token")
|
||||
if token:
|
||||
return str(token)
|
||||
except Exception as exc:
|
||||
logger.debug(f"CSRF: could not parse form body: {exc}")
|
||||
|
||||
return None
|
||||
+8
-1
@@ -26,12 +26,19 @@ original_template_response = templates.TemplateResponse
|
||||
|
||||
|
||||
def template_response_with_version(*args, **kwargs):
|
||||
"""Wrapper for TemplateResponse to include version in all templates"""
|
||||
"""Wrapper for TemplateResponse to include version and CSRF token in all templates"""
|
||||
# If context dict is provided, add version to it
|
||||
if len(args) >= 2 and isinstance(args[1], dict):
|
||||
args[1].setdefault("version", settings.version)
|
||||
# Inject CSRF token from request state when available
|
||||
req = args[1].get("request")
|
||||
if req is not None and hasattr(req.state, "csrf_token"):
|
||||
args[1].setdefault("csrf_token", req.state.csrf_token)
|
||||
elif "context" in kwargs and isinstance(kwargs["context"], dict):
|
||||
kwargs["context"].setdefault("version", settings.version)
|
||||
req = kwargs["context"].get("request")
|
||||
if req is not None and hasattr(req.state, "csrf_token"):
|
||||
kwargs["context"].setdefault("csrf_token", req.state.csrf_token)
|
||||
return original_template_response(*args, **kwargs)
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,39 @@
|
||||
// frontend/static/js/common.js
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CSRF token helper
|
||||
// ---------------------------------------------------------------------------
|
||||
// Read the CSRF token from the <meta name="csrf-token"> tag injected by the
|
||||
// server into base.html for every authenticated page.
|
||||
function getCsrfToken() {
|
||||
const meta = document.querySelector('meta[name="csrf-token"]');
|
||||
return meta ? meta.getAttribute('content') : '';
|
||||
}
|
||||
|
||||
// Wrap the native fetch() so that every state-changing request automatically
|
||||
// includes the X-CSRF-Token header without requiring callers to remember it.
|
||||
(function patchFetch() {
|
||||
const _CSRF_METHODS = new Set(['POST', 'PUT', 'DELETE', 'PATCH']);
|
||||
const _originalFetch = window.fetch;
|
||||
window.fetch = function (input, init) {
|
||||
init = init || {};
|
||||
const method = (init.method || 'GET').toUpperCase();
|
||||
if (_CSRF_METHODS.has(method)) {
|
||||
const token = getCsrfToken();
|
||||
if (token) {
|
||||
// Merge headers so a caller-supplied X-CSRF-Token is not overwritten,
|
||||
// but add the token when no override is present.
|
||||
const headers = Object.assign({}, init.headers || {});
|
||||
if (!headers['X-CSRF-Token']) {
|
||||
headers['X-CSRF-Token'] = token;
|
||||
}
|
||||
init.headers = headers;
|
||||
}
|
||||
}
|
||||
return _originalFetch.call(this, input, init);
|
||||
};
|
||||
})();
|
||||
|
||||
// Check authentication status and update the auth section
|
||||
(async function() {
|
||||
console.log('Checking authentication status...');
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
crossorigin="anonymous" referrerpolicy="no-referrer" />
|
||||
{% endblock %}
|
||||
{% block head_extra %}{% endblock %}
|
||||
<!-- CSRF token for AJAX/fetch requests -->
|
||||
<meta name="csrf-token" content="{{ csrf_token | default('', true) }}">
|
||||
</head>
|
||||
|
||||
<body class="bg-gray-50 min-h-screen flex flex-col">
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
<div class="mb-8" id="local-auth">
|
||||
<h2 class="text-lg font-semibold mb-4 text-gray-700">Sign in with username</h2>
|
||||
<form method="POST" action="/auth" class="space-y-4">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token | default('', true) }}">
|
||||
<div>
|
||||
<label for="username" class="block text-sm font-medium text-gray-700">Username</label>
|
||||
<input type="text" id="username" name="username" required
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
"""Tests for the CSRF protection middleware (app/middleware/csrf.py)."""
|
||||
|
||||
import secrets
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import Request
|
||||
from starlette.responses import JSONResponse, RedirectResponse
|
||||
|
||||
from app.middleware.csrf import CSRF_EXEMPT_PATHS, CSRF_PROTECTED_METHODS, CSRFMiddleware
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit tests – CSRFMiddleware._get_submitted_token
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestGetSubmittedToken:
|
||||
"""Unit tests for the CSRF token extraction helper."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_header_token(self):
|
||||
"""Token is read from the X-CSRF-Token request header."""
|
||||
mock_request = MagicMock(spec=Request)
|
||||
mock_request.headers = {"X-CSRF-Token": "abc123"}
|
||||
mock_request.form = AsyncMock(return_value={})
|
||||
|
||||
token = await CSRFMiddleware._get_submitted_token(mock_request)
|
||||
assert token == "abc123"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_form_token_for_urlencoded(self):
|
||||
"""Token is read from the form body for URL-encoded POST data."""
|
||||
mock_request = MagicMock(spec=Request)
|
||||
mock_request.headers = {"content-type": "application/x-www-form-urlencoded"}
|
||||
mock_request.form = AsyncMock(return_value={"csrf_token": "form_token_xyz"})
|
||||
|
||||
token = await CSRFMiddleware._get_submitted_token(mock_request)
|
||||
assert token == "form_token_xyz"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_header_takes_priority_over_form(self):
|
||||
"""Header token takes priority over form body token."""
|
||||
mock_request = MagicMock(spec=Request)
|
||||
mock_request.headers = {
|
||||
"X-CSRF-Token": "header_token",
|
||||
"content-type": "application/x-www-form-urlencoded",
|
||||
}
|
||||
mock_request.form = AsyncMock(return_value={"csrf_token": "form_token"})
|
||||
|
||||
token = await CSRFMiddleware._get_submitted_token(mock_request)
|
||||
assert token == "header_token"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_none_when_no_token(self):
|
||||
"""Returns None when no token is present in header or body."""
|
||||
mock_request = MagicMock(spec=Request)
|
||||
mock_request.headers = {"content-type": "application/json"}
|
||||
mock_request.form = AsyncMock(return_value={})
|
||||
|
||||
token = await CSRFMiddleware._get_submitted_token(mock_request)
|
||||
assert token is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_none_for_multipart_without_header(self):
|
||||
"""Multipart bodies without a header should return None (not parsed)."""
|
||||
mock_request = MagicMock(spec=Request)
|
||||
mock_request.headers = {"content-type": "multipart/form-data; boundary=----boundary"}
|
||||
mock_request.form = AsyncMock(return_value={"csrf_token": "should_not_be_read"})
|
||||
|
||||
token = await CSRFMiddleware._get_submitted_token(mock_request)
|
||||
assert token is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handles_form_parse_exception_gracefully(self):
|
||||
"""A broken form body does not crash the middleware."""
|
||||
mock_request = MagicMock(spec=Request)
|
||||
mock_request.headers = {"content-type": "application/x-www-form-urlencoded"}
|
||||
mock_request.form = AsyncMock(side_effect=Exception("parse error"))
|
||||
|
||||
token = await CSRFMiddleware._get_submitted_token(mock_request)
|
||||
assert token is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit tests – CSRFMiddleware.dispatch
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestCSRFMiddlewareDispatch:
|
||||
"""Unit tests for the CSRFMiddleware.dispatch method."""
|
||||
|
||||
def _make_middleware(self, auth_enabled: bool = True):
|
||||
mock_app = AsyncMock()
|
||||
mock_config = MagicMock()
|
||||
mock_config.auth_enabled = auth_enabled
|
||||
return CSRFMiddleware(mock_app, mock_config)
|
||||
|
||||
def _make_request(self, method="GET", path="/", session=None, headers=None, state=None):
|
||||
mock_request = MagicMock(spec=Request)
|
||||
mock_request.method = method
|
||||
mock_request.url = MagicMock()
|
||||
mock_request.url.path = path
|
||||
mock_request.session = session if session is not None else {}
|
||||
mock_request.headers = headers or {}
|
||||
mock_request.state = MagicMock()
|
||||
mock_request.state.csrf_token = None
|
||||
return mock_request
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_noop_when_auth_disabled(self):
|
||||
"""Middleware is a no-op when AUTH_ENABLED is False."""
|
||||
middleware = self._make_middleware(auth_enabled=False)
|
||||
request = self._make_request(method="POST", path="/api/test")
|
||||
|
||||
next_response = MagicMock()
|
||||
call_next = AsyncMock(return_value=next_response)
|
||||
|
||||
result = await middleware.dispatch(request, call_next)
|
||||
|
||||
call_next.assert_called_once_with(request)
|
||||
assert result is next_response
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generates_token_when_not_in_session(self):
|
||||
"""A new CSRF token is generated and stored in the session when absent."""
|
||||
middleware = self._make_middleware()
|
||||
request = self._make_request(method="GET", session={})
|
||||
call_next = AsyncMock(return_value=MagicMock())
|
||||
|
||||
await middleware.dispatch(request, call_next)
|
||||
|
||||
assert "csrf_token" in request.session
|
||||
token = request.session["csrf_token"]
|
||||
assert len(token) == 64 # secrets.token_hex(32) -> 64 hex chars
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reuses_existing_token_from_session(self):
|
||||
"""An existing session token is reused instead of regenerating."""
|
||||
existing_token = secrets.token_hex(32)
|
||||
middleware = self._make_middleware()
|
||||
request = self._make_request(method="GET", session={"csrf_token": existing_token})
|
||||
call_next = AsyncMock(return_value=MagicMock())
|
||||
|
||||
await middleware.dispatch(request, call_next)
|
||||
|
||||
assert request.session["csrf_token"] == existing_token
|
||||
assert request.state.csrf_token == existing_token
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_attaches_token_to_request_state(self):
|
||||
"""Token is always attached to request.state.csrf_token."""
|
||||
middleware = self._make_middleware()
|
||||
request = self._make_request(method="GET", session={})
|
||||
call_next = AsyncMock(return_value=MagicMock())
|
||||
|
||||
await middleware.dispatch(request, call_next)
|
||||
|
||||
assert request.state.csrf_token is not None
|
||||
assert len(request.state.csrf_token) == 64
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_safe_methods_pass_without_token(self):
|
||||
"""GET/HEAD/OPTIONS requests pass through without CSRF validation."""
|
||||
middleware = self._make_middleware()
|
||||
|
||||
for method in ("GET", "HEAD", "OPTIONS"):
|
||||
request = self._make_request(method=method, session={})
|
||||
call_next = AsyncMock(return_value=MagicMock())
|
||||
result = await middleware.dispatch(request, call_next)
|
||||
call_next.assert_called_once_with(request)
|
||||
call_next.reset_mock()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_post_with_valid_header_token_passes(self):
|
||||
"""POST with a matching X-CSRF-Token header passes validation."""
|
||||
token = secrets.token_hex(32)
|
||||
middleware = self._make_middleware()
|
||||
request = self._make_request(
|
||||
method="POST",
|
||||
path="/api/process/",
|
||||
session={"csrf_token": token},
|
||||
headers={"X-CSRF-Token": token},
|
||||
)
|
||||
call_next = AsyncMock(return_value=MagicMock())
|
||||
|
||||
with patch.object(CSRFMiddleware, "_get_submitted_token", new=AsyncMock(return_value=token)):
|
||||
result = await middleware.dispatch(request, call_next)
|
||||
|
||||
call_next.assert_called_once_with(request)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_post_with_invalid_token_returns_403_for_api(self):
|
||||
"""POST with a wrong token on an API route returns HTTP 403."""
|
||||
token = secrets.token_hex(32)
|
||||
middleware = self._make_middleware()
|
||||
request = self._make_request(
|
||||
method="POST",
|
||||
path="/api/process/",
|
||||
session={"csrf_token": token},
|
||||
)
|
||||
call_next = AsyncMock(return_value=MagicMock())
|
||||
|
||||
with patch.object(CSRFMiddleware, "_get_submitted_token", new=AsyncMock(return_value="wrong_token")):
|
||||
result = await middleware.dispatch(request, call_next)
|
||||
|
||||
assert isinstance(result, JSONResponse)
|
||||
assert result.status_code == 403
|
||||
call_next.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_post_with_missing_token_returns_403_for_api(self):
|
||||
"""POST with no CSRF token on an API route returns HTTP 403."""
|
||||
token = secrets.token_hex(32)
|
||||
middleware = self._make_middleware()
|
||||
request = self._make_request(
|
||||
method="POST",
|
||||
path="/api/settings/bulk-update",
|
||||
session={"csrf_token": token},
|
||||
)
|
||||
call_next = AsyncMock(return_value=MagicMock())
|
||||
|
||||
with patch.object(CSRFMiddleware, "_get_submitted_token", new=AsyncMock(return_value=None)):
|
||||
result = await middleware.dispatch(request, call_next)
|
||||
|
||||
assert isinstance(result, JSONResponse)
|
||||
assert result.status_code == 403
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_post_with_invalid_token_redirects_for_frontend(self):
|
||||
"""POST with a wrong token on a frontend route redirects to /login."""
|
||||
token = secrets.token_hex(32)
|
||||
middleware = self._make_middleware()
|
||||
request = self._make_request(
|
||||
method="POST",
|
||||
path="/auth",
|
||||
session={"csrf_token": token},
|
||||
)
|
||||
call_next = AsyncMock(return_value=MagicMock())
|
||||
|
||||
with patch.object(CSRFMiddleware, "_get_submitted_token", new=AsyncMock(return_value="bad_token")):
|
||||
result = await middleware.dispatch(request, call_next)
|
||||
|
||||
assert isinstance(result, RedirectResponse)
|
||||
assert result.status_code == 302
|
||||
assert "/login?error=Invalid+request" in result.headers["location"]
|
||||
call_next.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_with_valid_token_passes(self):
|
||||
"""DELETE with a matching token passes through."""
|
||||
token = secrets.token_hex(32)
|
||||
middleware = self._make_middleware()
|
||||
request = self._make_request(
|
||||
method="DELETE",
|
||||
path="/api/files/1",
|
||||
session={"csrf_token": token},
|
||||
)
|
||||
call_next = AsyncMock(return_value=MagicMock())
|
||||
|
||||
with patch.object(CSRFMiddleware, "_get_submitted_token", new=AsyncMock(return_value=token)):
|
||||
result = await middleware.dispatch(request, call_next)
|
||||
|
||||
call_next.assert_called_once_with(request)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_oauth_callback_is_exempt(self):
|
||||
"""OAuth callback path is exempt from CSRF validation even on POST."""
|
||||
middleware = self._make_middleware()
|
||||
request = self._make_request(
|
||||
method="POST",
|
||||
path="/oauth-callback",
|
||||
session={"csrf_token": secrets.token_hex(32)},
|
||||
)
|
||||
call_next = AsyncMock(return_value=MagicMock())
|
||||
|
||||
with patch.object(CSRFMiddleware, "_get_submitted_token", new=AsyncMock(return_value=None)):
|
||||
result = await middleware.dispatch(request, call_next)
|
||||
|
||||
call_next.assert_called_once_with(request)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration tests – via TestClient
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.security
|
||||
class TestCSRFIntegration:
|
||||
"""Integration tests for CSRF protection using the FastAPI TestClient.
|
||||
|
||||
The shared ``client`` fixture runs with ``AUTH_ENABLED=False`` (see
|
||||
``conftest.py``), so the CSRF middleware is disabled by design. The tests
|
||||
below verify constants and confirm that the middleware is a no-op in that
|
||||
configuration.
|
||||
"""
|
||||
|
||||
def test_csrf_constants(self):
|
||||
"""Verify the constant sets have the expected members."""
|
||||
assert "POST" in CSRF_PROTECTED_METHODS
|
||||
assert "PUT" in CSRF_PROTECTED_METHODS
|
||||
assert "DELETE" in CSRF_PROTECTED_METHODS
|
||||
assert "PATCH" in CSRF_PROTECTED_METHODS
|
||||
assert "GET" not in CSRF_PROTECTED_METHODS
|
||||
assert "/oauth-callback" in CSRF_EXEMPT_PATHS
|
||||
|
||||
def test_csrf_middleware_noop_when_auth_disabled(self):
|
||||
"""When AUTH_ENABLED=False the middleware dispatch is a no-op (no validation)."""
|
||||
# Build a middleware instance with auth disabled.
|
||||
mock_app = AsyncMock()
|
||||
mock_config = MagicMock()
|
||||
mock_config.auth_enabled = False
|
||||
middleware = CSRFMiddleware(mock_app, mock_config)
|
||||
|
||||
import asyncio
|
||||
|
||||
mock_request = MagicMock(spec=Request)
|
||||
mock_request.method = "POST"
|
||||
mock_request.url = MagicMock()
|
||||
mock_request.url.path = "/api/process/"
|
||||
mock_request.session = {}
|
||||
|
||||
call_next = AsyncMock(return_value=MagicMock())
|
||||
asyncio.run(middleware.dispatch(mock_request, call_next))
|
||||
|
||||
# call_next must have been called (request was not blocked).
|
||||
call_next.assert_called_once_with(mock_request)
|
||||
# Session should remain untouched (no token generated).
|
||||
assert "csrf_token" not in mock_request.session
|
||||
Reference in New Issue
Block a user