SSE: CSRF-middleware на чистом ASGI (BaseHTTPMiddleware буферизует поток и ломает SSE) + тест CSRF
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+18
-9
@@ -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,13 +33,21 @@ _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"):
|
"""
|
||||||
|
|
||||||
|
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 = (
|
has_session = (
|
||||||
security.USER_COOKIE in request.cookies
|
security.USER_COOKIE in request.cookies
|
||||||
or security.ADMIN_COOKIE in request.cookies
|
or security.ADMIN_COOKIE in request.cookies
|
||||||
@@ -49,7 +56,7 @@ class CSRFMiddleware(BaseHTTPMiddleware):
|
|||||||
cookie_token = request.cookies.get(security.CSRF_COOKIE)
|
cookie_token = request.cookies.get(security.CSRF_COOKIE)
|
||||||
header_token = request.headers.get(security.CSRF_HEADER)
|
header_token = request.headers.get(security.CSRF_HEADER)
|
||||||
if not cookie_token or cookie_token != header_token:
|
if not cookie_token or cookie_token != header_token:
|
||||||
return JSONResponse(
|
response = JSONResponse(
|
||||||
status_code=403,
|
status_code=403,
|
||||||
content={
|
content={
|
||||||
"error": {
|
"error": {
|
||||||
@@ -59,7 +66,9 @@ class CSRFMiddleware(BaseHTTPMiddleware):
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
return await call_next(request)
|
await response(scope, receive, send)
|
||||||
|
return
|
||||||
|
await self.app(scope, receive, send)
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
|
|||||||
@@ -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, "Капитан")
|
||||||
|
|||||||
Reference in New Issue
Block a user