From 9eb6103a4dfd07fff34d9840e86b259d09cf8ad1 Mon Sep 17 00:00:00 2001 From: 1445043649 <> Date: Sun, 28 Jun 2026 12:06:23 +0800 Subject: [PATCH] feat(csrf): skip CSRF for bearer-header requests (Stage 1 PR3) Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/app/gateway/csrf_middleware.py | 15 +++++++- backend/tests/test_csrf_bearer.py | 49 ++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 backend/tests/test_csrf_bearer.py diff --git a/backend/app/gateway/csrf_middleware.py b/backend/app/gateway/csrf_middleware.py index 08e95be4..8a7329bc 100644 --- a/backend/app/gateway/csrf_middleware.py +++ b/backend/app/gateway/csrf_middleware.py @@ -29,15 +29,28 @@ def generate_csrf_token() -> str: return secrets.token_urlsafe(CSRF_TOKEN_LENGTH) +def has_bearer_header(request: Request) -> bool: + """True if the request carries an ``Authorization: Bearer ...`` header. + + Bearer requests authenticate via header, not cookie, so they are not + vulnerable to CSRF (the browser never auto-attaches a bearer header). + """ + return request.headers.get("authorization", "").startswith("Bearer ") + + def should_check_csrf(request: Request) -> bool: """Determine if a request needs CSRF validation. CSRF is checked for state-changing methods (POST, PUT, DELETE, PATCH). - GET, HEAD, OPTIONS, and TRACE are exempt per RFC 7231. + GET, HEAD, OPTIONS, and TRACE are exempt per RFC 7231. Bearer-header + (API key / token) requests are exempt — they don't ride on cookies. """ if request.method not in ("POST", "PUT", "DELETE", "PATCH"): return False + if has_bearer_header(request): + return False + path = request.url.path.rstrip("/") # Exempt /api/v1/auth/me endpoint if path == "/api/v1/auth/me": diff --git a/backend/tests/test_csrf_bearer.py b/backend/tests/test_csrf_bearer.py new file mode 100644 index 00000000..bd5b603d --- /dev/null +++ b/backend/tests/test_csrf_bearer.py @@ -0,0 +1,49 @@ +"""CSRF bearer-skip tests (Stage 1 PR3).""" + +from __future__ import annotations + +from starlette.testclient import TestClient + + +def _make_app(): + from fastapi import FastAPI + + from app.gateway.csrf_middleware import CSRFMiddleware + + app = FastAPI() + app.add_middleware(CSRFMiddleware) + + @app.post("/api/echo") + async def echo(): + return {"ok": True} + + return app + + +def test_bearer_post_skips_csrf(): + client = TestClient(_make_app()) + # No X-CSRF-Token / csrf cookie, but bearer header present → allowed. + r = client.post("/api/echo", headers={"Authorization": "Bearer dfk_live_anything"}) + assert r.status_code == 200 + + +def test_cookie_post_still_requires_csrf(): + client = TestClient(_make_app()) + # No bearer, no CSRF token → 403 (regression: cookie path unchanged). + r = client.post("/api/echo") + assert r.status_code == 403 + assert "CSRF token missing" in r.json()["detail"] + + +def test_has_bearer_header_detection(): + from starlette.requests import Request + + from app.gateway.csrf_middleware import has_bearer_header + + def _req(headers): + scope = {"type": "http", "headers": [(k.lower().encode(), v.encode()) for k, v in headers.items()]} + return Request(scope) + + assert has_bearer_header(_req({"authorization": "Bearer x"})) is True + assert has_bearer_header(_req({"authorization": "Basic x"})) is False + assert has_bearer_header(_req({})) is False