Fix CSRF validation for duplicate cookie values on PATCH

This commit is contained in:
2026-03-02 18:09:27 -03:00
parent 2a5dfc3713
commit 1a04b23e89
3 changed files with 86 additions and 2 deletions

View File

@@ -54,6 +54,28 @@ def _requires_csrf_validation(method: str) -> bool:
return method.upper() in CSRF_PROTECTED_METHODS return method.upper() in CSRF_PROTECTED_METHODS
def _extract_cookie_values(request: Request, cookie_name: str) -> tuple[str, ...]:
"""Extracts all values for one cookie name from raw Cookie header order."""
request_headers = getattr(request, "headers", None)
raw_cookie_header = request_headers.get("cookie", "") if request_headers is not None else ""
if not raw_cookie_header:
return ()
extracted_values: list[str] = []
for cookie_pair in raw_cookie_header.split(";"):
normalized_pair = cookie_pair.strip()
if not normalized_pair or "=" not in normalized_pair:
continue
key, value = normalized_pair.split("=", 1)
if key.strip() != cookie_name:
continue
normalized_value = value.strip()
if normalized_value:
extracted_values.append(normalized_value)
return tuple(extracted_values)
def _raise_unauthorized() -> None: def _raise_unauthorized() -> None:
"""Raises a 401 challenge response for missing or invalid auth sessions.""" """Raises a 401 challenge response for missing or invalid auth sessions."""
@@ -94,11 +116,14 @@ def get_request_auth_context(
if _requires_csrf_validation(request.method) and using_cookie_session: if _requires_csrf_validation(request.method) and using_cookie_session:
normalized_csrf_header = (csrf_header or "").strip() normalized_csrf_header = (csrf_header or "").strip()
csrf_candidates = [candidate for candidate in _extract_cookie_values(request, CSRF_COOKIE_NAME) if candidate]
normalized_csrf_cookie = (csrf_cookie or "").strip() normalized_csrf_cookie = (csrf_cookie or "").strip()
if normalized_csrf_cookie and normalized_csrf_cookie not in csrf_candidates:
csrf_candidates.append(normalized_csrf_cookie)
if ( if (
not normalized_csrf_cookie not csrf_candidates
or not normalized_csrf_header or not normalized_csrf_header
or not hmac.compare_digest(normalized_csrf_cookie, normalized_csrf_header) or not any(hmac.compare_digest(candidate, normalized_csrf_header) for candidate in csrf_candidates)
): ):
_raise_csrf_rejected() _raise_csrf_rejected()

View File

@@ -364,6 +364,7 @@ if "app.services.routing_pipeline" not in sys.modules:
from fastapi import HTTPException from fastapi import HTTPException
from app.api.auth import AuthContext, require_admin from app.api.auth import AuthContext, require_admin
from app.api import auth as auth_dependency_module
from app.api import routes_auth as auth_routes_module from app.api import routes_auth as auth_routes_module
from app.api import routes_documents as documents_routes_module from app.api import routes_documents as documents_routes_module
from app.core import config as config_module from app.core import config as config_module
@@ -420,6 +421,63 @@ class AuthDependencyTests(unittest.TestCase):
resolved = require_admin(context=auth_context) resolved = require_admin(context=auth_context)
self.assertEqual(resolved.role, UserRole.ADMIN) self.assertEqual(resolved.role, UserRole.ADMIN)
def test_csrf_validation_accepts_matching_token_among_duplicate_cookie_values(self) -> None:
"""PATCH CSRF validation accepts header token matching any duplicate csrf cookie value."""
request = SimpleNamespace(
method="PATCH",
headers={"cookie": "dcm_session=session-token; dcm_csrf=stale-token; dcm_csrf=fresh-token"},
)
resolved_session = SimpleNamespace(
id=uuid.uuid4(),
expires_at=datetime.now(UTC),
user=SimpleNamespace(
id=uuid.uuid4(),
username="admin",
role=UserRole.ADMIN,
),
)
with patch.object(auth_dependency_module, "resolve_auth_session", return_value=resolved_session):
context = auth_dependency_module.get_request_auth_context(
request=request,
credentials=None,
csrf_header="fresh-token",
csrf_cookie="stale-token",
session_cookie="session-token",
session=SimpleNamespace(),
)
self.assertEqual(context.username, "admin")
self.assertEqual(context.role, UserRole.ADMIN)
def test_csrf_validation_rejects_when_header_does_not_match_any_cookie_value(self) -> None:
"""PATCH CSRF validation rejects requests when header token matches no csrf cookie values."""
request = SimpleNamespace(
method="PATCH",
headers={"cookie": "dcm_session=session-token; dcm_csrf=stale-token; dcm_csrf=fresh-token"},
)
resolved_session = SimpleNamespace(
id=uuid.uuid4(),
expires_at=datetime.now(UTC),
user=SimpleNamespace(
id=uuid.uuid4(),
username="admin",
role=UserRole.ADMIN,
),
)
with patch.object(auth_dependency_module, "resolve_auth_session", return_value=resolved_session):
with self.assertRaises(HTTPException) as raised:
auth_dependency_module.get_request_auth_context(
request=request,
credentials=None,
csrf_header="unknown-token",
csrf_cookie="stale-token",
session_cookie="session-token",
session=SimpleNamespace(),
)
self.assertEqual(raised.exception.status_code, 403)
self.assertEqual(raised.exception.detail, "Invalid CSRF token")
class DocumentCatalogVisibilityTests(unittest.TestCase): class DocumentCatalogVisibilityTests(unittest.TestCase):
"""Verifies predefined tag and path discovery visibility by caller role.""" """Verifies predefined tag and path discovery visibility by caller role."""

View File

@@ -137,6 +137,7 @@ Recommended LIVE pattern:
- `VITE_ALLOWED_HOSTS` only affects development mode where Vite is running. - `VITE_ALLOWED_HOSTS` only affects development mode where Vite is running.
- API auth cookies support optional domain and SameSite configuration through `AUTH_COOKIE_DOMAIN` and `AUTH_COOKIE_SAMESITE`. - API auth cookies support optional domain and SameSite configuration through `AUTH_COOKIE_DOMAIN` and `AUTH_COOKIE_SAMESITE`.
- HTTPS cookie security detection falls back to `PUBLIC_BASE_URL` scheme when proxy headers are missing. - HTTPS cookie security detection falls back to `PUBLIC_BASE_URL` scheme when proxy headers are missing.
- CSRF validation accepts header matches against any `dcm_csrf` cookie value in the request, covering stale plus fresh duplicate-cookie transitions.
- Session authentication is cookie-based; browser reloads and new tabs can reuse an active session until it expires or is revoked. - Session authentication is cookie-based; browser reloads and new tabs can reuse an active session until it expires or is revoked.
- Protected media and file download flows still use authenticated fetch plus blob/object URL handling. - Protected media and file download flows still use authenticated fetch plus blob/object URL handling.