diff --git a/datasette/csrf.py b/datasette/csrf.py index dd968a4d..845c8fb4 100644 --- a/datasette/csrf.py +++ b/datasette/csrf.py @@ -68,7 +68,10 @@ class CrossOriginProtectionMiddleware: # before evaluating Sec-Fetch-Site / Origin. Only "Bearer" is exempt; # schemes like Basic or Digest can be browser-managed and ambient. authorization = headers.get(b"authorization", b"").decode("latin-1") - if authorization: + cookie_header = headers.get(b"cookie") + # If the request also carries a Cookie header, ambient cookie auth + # could be in play, so do NOT treat it as exempt. + if authorization and not cookie_header: scheme = authorization.split(None, 1)[0].lower() if scheme == "bearer": await self.app(scope, receive, send) diff --git a/tests/test_csrf_middleware.py b/tests/test_csrf_middleware.py index 07ff598e..820df1e7 100644 --- a/tests/test_csrf_middleware.py +++ b/tests/test_csrf_middleware.py @@ -252,6 +252,20 @@ async def test_cross_site_post_without_auth_still_blocked(bare_ds): assert response.status_code == 403 +@pytest.mark.asyncio +async def test_bearer_with_cookie_does_not_bypass(): + # Bearer + Cookie => ambient cookie auth is in play, not exempt. + scope = _http_scope( + { + "sec-fetch-site": "cross-site", + "host": "example.com", + "authorization": "Bearer dstok_abc", + "cookie": "ds_actor=anything", + } + ) + assert await _run_middleware(scope) == ("blocked", 403) + + def test_legacy_skip_csrf_hookimpl_does_not_break_loading(): # Plugins that still define skip_csrf must load cleanly - pluggy ignores # unknown hook implementations - even though the hook is no longer