diff --git a/backend/app/main.py b/backend/app/main.py index ff1b71e..bd9484f 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -10,7 +10,6 @@ from fastapi import FastAPI, Request from fastapi.exceptions import RequestValidationError from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, JSONResponse -from starlette.middleware.base import BaseHTTPMiddleware from app.core import security 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"} -class CSRFMiddleware(BaseHTTPMiddleware): - """Double-submit CSRF: для аутентифицированных мутаций на /api требуем - совпадения заголовка X-CSRF-Token и cookie csrf_token.""" +class CSRFMiddleware: + """Double-submit CSRF на чистом ASGI: для аутентифицированных мутаций на /api требуем + совпадения заголовка X-CSRF-Token и cookie csrf_token. - async def dispatch(self, request: Request, call_next): # noqa: ANN001 - path = request.url.path - if request.method in _UNSAFE_METHODS and path.startswith("/api"): - has_session = ( - security.USER_COOKIE in request.cookies - or security.ADMIN_COOKIE in request.cookies - ) - if has_session: - cookie_token = request.cookies.get(security.CSRF_COOKIE) - header_token = request.headers.get(security.CSRF_HEADER) - if not cookie_token or cookie_token != header_token: - return JSONResponse( - status_code=403, - content={ - "error": { - "code": "CSRF_FAILED", - "message": "Неверный или отсутствующий CSRF-токен.", - "details": None, - } - }, - ) - return await call_next(request) + Намеренно НЕ на BaseHTTPMiddleware: тот буферизует потоковые ответы и ломает SSE + (/api/events). Чистый ASGI пропускает стримы насквозь, вмешиваясь только при отказе CSRF. + """ + + def __init__(self, app) -> None: # noqa: ANN001 + self.app = app + + async def __call__(self, scope, receive, send): # noqa: ANN001 + if scope["type"] == "http": + request = Request(scope) + if request.method in _UNSAFE_METHODS and request.url.path.startswith("/api"): + has_session = ( + security.USER_COOKIE in request.cookies + or security.ADMIN_COOKIE in request.cookies + ) + if has_session: + cookie_token = request.cookies.get(security.CSRF_COOKIE) + header_token = request.headers.get(security.CSRF_HEADER) + if not cookie_token or cookie_token != header_token: + response = JSONResponse( + status_code=403, + content={ + "error": { + "code": "CSRF_FAILED", + "message": "Неверный или отсутствующий CSRF-токен.", + "details": None, + } + }, + ) + await response(scope, receive, send) + return + await self.app(scope, receive, send) @asynccontextmanager diff --git a/backend/tests/test_core_flow.py b/backend/tests/test_core_flow.py index 4a6f407..e55ea9e 100644 --- a/backend/tests/test_core_flow.py +++ b/backend/tests/test_core_flow.py @@ -287,6 +287,19 @@ def test_disabled_account_cannot_login(client: TestClient, make_admin): 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): """Участники без завершённых партий попадают в отдельный блок inactive (не в provisional).""" me = login(client, "Капитан")