SSE: CSRF-middleware на чистом ASGI (BaseHTTPMiddleware буферизует поток и ломает SSE) + тест CSRF

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-17 23:08:10 +03:00
co-authored by Claude Opus 4.8
parent 2500ca575b
commit ef5c242e16
2 changed files with 48 additions and 26 deletions
+35 -26
View File
@@ -10,7 +10,6 @@ from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse from fastapi.responses import FileResponse, JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware
from app.core import security from app.core import security
from app.core.config import settings from app.core.config import settings
@@ -34,32 +33,42 @@ _STATIC_DIR = Path(os.getenv("STATIC_DIR", str(Path(__file__).resolve().parent.p
_UNSAFE_METHODS = {"POST", "PUT", "PATCH", "DELETE"} _UNSAFE_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
class CSRFMiddleware(BaseHTTPMiddleware): class CSRFMiddleware:
"""Double-submit CSRF: для аутентифицированных мутаций на /api требуем """Double-submit CSRF на чистом ASGI: для аутентифицированных мутаций на /api требуем
совпадения заголовка X-CSRF-Token и cookie csrf_token.""" совпадения заголовка X-CSRF-Token и cookie csrf_token.
async def dispatch(self, request: Request, call_next): # noqa: ANN001 Намеренно НЕ на BaseHTTPMiddleware: тот буферизует потоковые ответы и ломает SSE
path = request.url.path (/api/events). Чистый ASGI пропускает стримы насквозь, вмешиваясь только при отказе CSRF.
if request.method in _UNSAFE_METHODS and path.startswith("/api"): """
has_session = (
security.USER_COOKIE in request.cookies def __init__(self, app) -> None: # noqa: ANN001
or security.ADMIN_COOKIE in request.cookies self.app = app
)
if has_session: async def __call__(self, scope, receive, send): # noqa: ANN001
cookie_token = request.cookies.get(security.CSRF_COOKIE) if scope["type"] == "http":
header_token = request.headers.get(security.CSRF_HEADER) request = Request(scope)
if not cookie_token or cookie_token != header_token: if request.method in _UNSAFE_METHODS and request.url.path.startswith("/api"):
return JSONResponse( has_session = (
status_code=403, security.USER_COOKIE in request.cookies
content={ or security.ADMIN_COOKIE in request.cookies
"error": { )
"code": "CSRF_FAILED", if has_session:
"message": "Неверный или отсутствующий CSRF-токен.", cookie_token = request.cookies.get(security.CSRF_COOKIE)
"details": None, header_token = request.headers.get(security.CSRF_HEADER)
} if not cookie_token or cookie_token != header_token:
}, response = JSONResponse(
) status_code=403,
return await call_next(request) content={
"error": {
"code": "CSRF_FAILED",
"message": "Неверный или отсутствующий CSRF-токен.",
"details": None,
}
},
)
await response(scope, receive, send)
return
await self.app(scope, receive, send)
@asynccontextmanager @asynccontextmanager
+13
View File
@@ -287,6 +287,19 @@ def test_disabled_account_cannot_login(client: TestClient, make_admin):
assert guest_dev["is_active"] is False assert guest_dev["is_active"] is False
def test_csrf_required_for_session_mutations(client: TestClient):
"""После рефактора CSRF на ASGI защита сохраняется: мутация с сессией без X-CSRF-Token → 403."""
login(client, "Аня") # появились cookie сессии и csrf_token
no_header = client.post("/api/groups", json={"name": "Группа", "expansion_ids": []})
assert no_header.status_code == 403
assert no_header.json()["error"]["code"] == "CSRF_FAILED"
# С корректным заголовком — проходит.
ok = client.post(
"/api/groups", json={"name": "Группа", "expansion_ids": []}, headers=csrf_headers(client)
)
assert ok.status_code == 200, ok.text
def test_group_stats_includes_inactive_members(client: TestClient, engine): def test_group_stats_includes_inactive_members(client: TestClient, engine):
"""Участники без завершённых партий попадают в отдельный блок inactive (не в provisional).""" """Участники без завершённых партий попадают в отдельный блок inactive (не в provisional)."""
me = login(client, "Капитан") me = login(client, "Капитан")