Fix CSRF cookie not being set on HTML responses
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:
2026-01-08 16:01:30 +00:00
parent b306fa022c
commit cd01daec6d

View File

@@ -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