feat(csrf): skip CSRF for bearer-header requests (Stage 1 PR3)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -29,15 +29,28 @@ def generate_csrf_token() -> str:
|
|||||||
return secrets.token_urlsafe(CSRF_TOKEN_LENGTH)
|
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:
|
def should_check_csrf(request: Request) -> bool:
|
||||||
"""Determine if a request needs CSRF validation.
|
"""Determine if a request needs CSRF validation.
|
||||||
|
|
||||||
CSRF is checked for state-changing methods (POST, PUT, DELETE, PATCH).
|
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"):
|
if request.method not in ("POST", "PUT", "DELETE", "PATCH"):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
if has_bearer_header(request):
|
||||||
|
return False
|
||||||
|
|
||||||
path = request.url.path.rstrip("/")
|
path = request.url.path.rstrip("/")
|
||||||
# Exempt /api/v1/auth/me endpoint
|
# Exempt /api/v1/auth/me endpoint
|
||||||
if path == "/api/v1/auth/me":
|
if path == "/api/v1/auth/me":
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user