Fix CSRF cookie not being set on HTML responses
All checks were successful
Deploy / deploy (push) Successful in 1m38s
All checks were successful
Deploy / deploy (push) Successful in 1m38s
FastHTML's fast_app() silently ignores the 'after' parameter - it only supports 'before'. The afterware function was never being called, so the CSRF cookie was never set, causing 403 Forbidden on all POST requests in production. Replaced the non-functional afterware with proper Starlette ASGI middleware (CsrfCookieMiddleware) that intercepts responses and adds the Set-Cookie header directly to HTML GET responses. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -65,6 +65,70 @@ class StaticCacheMiddleware:
|
|||||||
await self.app(scope, receive, send_with_headers)
|
await self.app(scope, receive, send_with_headers)
|
||||||
|
|
||||||
|
|
||||||
|
class CsrfCookieMiddleware:
|
||||||
|
"""Middleware to set CSRF cookie on HTML responses.
|
||||||
|
|
||||||
|
FastHTML's afterware doesn't work for setting cookies because:
|
||||||
|
1. fast_app() ignores the 'after' parameter
|
||||||
|
2. Even if it didn't, afterware receives FT components, not Response objects
|
||||||
|
|
||||||
|
This Starlette middleware intercepts responses and adds the CSRF cookie
|
||||||
|
to HTML GET responses that don't already have one.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, app, settings: Settings):
|
||||||
|
self.app = app
|
||||||
|
self.settings = settings
|
||||||
|
|
||||||
|
async def __call__(self, scope, receive, send):
|
||||||
|
if scope["type"] != "http":
|
||||||
|
await self.app(scope, receive, send)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Skip in dev mode
|
||||||
|
if self.settings.dev_mode:
|
||||||
|
await self.app(scope, receive, send)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Only process GET requests
|
||||||
|
if scope.get("method") != "GET":
|
||||||
|
await self.app(scope, receive, send)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check if csrf cookie already in request
|
||||||
|
headers = dict(scope.get("headers", []))
|
||||||
|
cookie_header = headers.get(b"cookie", b"").decode()
|
||||||
|
if f"{self.settings.csrf_cookie_name}=" in cookie_header:
|
||||||
|
await self.app(scope, receive, send)
|
||||||
|
return
|
||||||
|
|
||||||
|
async def send_with_csrf_cookie(message):
|
||||||
|
if message["type"] == "http.response.start":
|
||||||
|
headers = list(message.get("headers", []))
|
||||||
|
|
||||||
|
# Check content-type
|
||||||
|
content_type = ""
|
||||||
|
for name, value in headers:
|
||||||
|
if name == b"content-type":
|
||||||
|
content_type = value.decode()
|
||||||
|
break
|
||||||
|
|
||||||
|
# Only add cookie to HTML responses
|
||||||
|
if "text/html" in content_type:
|
||||||
|
token = generate_csrf_token(self.settings.csrf_secret)
|
||||||
|
secure_flag = "; Secure" if not self.settings.dev_mode else ""
|
||||||
|
cookie_value = (
|
||||||
|
f"{self.settings.csrf_cookie_name}={token}; "
|
||||||
|
f"Path=/; SameSite=Strict; Max-Age=86400{secure_flag}"
|
||||||
|
).encode()
|
||||||
|
headers.append((b"set-cookie", cookie_value))
|
||||||
|
message = {**message, "headers": headers}
|
||||||
|
|
||||||
|
await send(message)
|
||||||
|
|
||||||
|
await self.app(scope, receive, send_with_csrf_cookie)
|
||||||
|
|
||||||
|
|
||||||
def create_app(
|
def create_app(
|
||||||
settings: Settings | None = None,
|
settings: Settings | None = None,
|
||||||
db=None,
|
db=None,
|
||||||
@@ -106,39 +170,6 @@ def create_app(
|
|||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def after(req: Request, resp, sess):
|
|
||||||
"""Afterware to set CSRF cookie on page responses."""
|
|
||||||
# Skip in dev mode (CSRF is bypassed anyway)
|
|
||||||
if settings.dev_mode:
|
|
||||||
return resp
|
|
||||||
|
|
||||||
# Only set cookie on GET requests (page loads)
|
|
||||||
if req.method != "GET":
|
|
||||||
return resp
|
|
||||||
|
|
||||||
# Check if cookie already set
|
|
||||||
if settings.csrf_cookie_name in req.cookies:
|
|
||||||
return resp
|
|
||||||
|
|
||||||
# Skip non-HTML responses (API endpoints, static files, etc.)
|
|
||||||
content_type = resp.headers.get("content-type", "")
|
|
||||||
if "text/html" not in content_type:
|
|
||||||
return resp
|
|
||||||
|
|
||||||
# Generate and set token
|
|
||||||
token = generate_csrf_token(settings.csrf_secret)
|
|
||||||
|
|
||||||
# Set cookie - httponly=False so JS can read it for HTMX
|
|
||||||
resp.set_cookie(
|
|
||||||
settings.csrf_cookie_name,
|
|
||||||
token,
|
|
||||||
httponly=False,
|
|
||||||
samesite="strict",
|
|
||||||
secure=not settings.dev_mode,
|
|
||||||
max_age=86400, # 24 hours
|
|
||||||
)
|
|
||||||
return resp
|
|
||||||
|
|
||||||
# Configure beforeware with skip patterns
|
# Configure beforeware with skip patterns
|
||||||
beforeware = Beforeware(
|
beforeware = Beforeware(
|
||||||
before,
|
before,
|
||||||
@@ -171,13 +202,17 @@ def create_app(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Create FastHTML app with HTMX extensions, MonsterUI theme, and static path
|
# Create FastHTML app with HTMX extensions, MonsterUI theme, and static path
|
||||||
|
# Note: CsrfCookieMiddleware must come before StaticCacheMiddleware in the list
|
||||||
|
# because Starlette applies middleware in reverse order (last in list wraps first)
|
||||||
app, rt = fast_app(
|
app, rt = fast_app(
|
||||||
before=beforeware,
|
before=beforeware,
|
||||||
after=after,
|
|
||||||
hdrs=(*Theme.slate.headers(), htmx_config), # Dark industrial theme + HTMX config
|
hdrs=(*Theme.slate.headers(), htmx_config), # Dark industrial theme + HTMX config
|
||||||
exts=["head-support", "preload"],
|
exts=["head-support", "preload"],
|
||||||
static_path=static_path_for_fasthtml,
|
static_path=static_path_for_fasthtml,
|
||||||
middleware=[Middleware(StaticCacheMiddleware)],
|
middleware=[
|
||||||
|
Middleware(CsrfCookieMiddleware, settings=settings),
|
||||||
|
Middleware(StaticCacheMiddleware),
|
||||||
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
# Store settings and db on app state for access in routes
|
# Store settings and db on app state for access in routes
|
||||||
|
|||||||
Reference in New Issue
Block a user