diff --git a/app/middleware/csrf.py b/app/middleware/csrf.py index 842958af..f21088ad 100644 --- a/app/middleware/csrf.py +++ b/app/middleware/csrf.py @@ -20,6 +20,9 @@ How it works: Exempt paths (CSRF is not checked even for state-changing methods): - ``/oauth-callback`` – OAuth 2.0 callback; protected by the ``state`` parameter. +- ``/api/qr-auth/claim`` – Called by the unauthenticated mobile app; the + cryptographically-random, single-use challenge token provides equivalent + protection. """ import logging @@ -39,6 +42,10 @@ CSRF_PROTECTED_METHODS = {"POST", "PUT", "DELETE", "PATCH"} # their own replay-protection mechanism). CSRF_EXEMPT_PATHS = { "/oauth-callback", + # The mobile app calls this endpoint without a browser session/CSRF token. + # The cryptographically-random, single-use challenge token already provides + # equivalent protection against cross-site request forgery. + "/api/qr-auth/claim", } diff --git a/tests/test_csrf.py b/tests/test_csrf.py index a6041038..a80e6e8a 100644 --- a/tests/test_csrf.py +++ b/tests/test_csrf.py @@ -337,6 +337,27 @@ class TestCSRFMiddlewareDispatch: call_next.assert_called_once_with(request) + @pytest.mark.asyncio + async def test_qr_auth_claim_is_exempt(self): + """QR auth claim path is exempt from CSRF validation. + + The mobile app calls this endpoint without a browser session and + therefore without a CSRF token. The cryptographically-random, + single-use challenge token provides equivalent protection. + """ + middleware = self._make_middleware() + request = self._make_request( + method="POST", + path="/api/qr-auth/claim", + session={}, + ) + 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 @@ -362,6 +383,7 @@ class TestCSRFIntegration: assert "PATCH" in CSRF_PROTECTED_METHODS assert "GET" not in CSRF_PROTECTED_METHODS assert "/oauth-callback" in CSRF_EXEMPT_PATHS + assert "/api/qr-auth/claim" 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)."""