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)
|
||||
|
||||
|
||||
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(
|
||||
settings: Settings | None = None,
|
||||
db=None,
|
||||
@@ -106,39 +170,6 @@ def create_app(
|
||||
|
||||
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
|
||||
beforeware = Beforeware(
|
||||
before,
|
||||
@@ -171,13 +202,17 @@ def create_app(
|
||||
)
|
||||
|
||||
# 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(
|
||||
before=beforeware,
|
||||
after=after,
|
||||
hdrs=(*Theme.slate.headers(), htmx_config), # Dark industrial theme + HTMX config
|
||||
exts=["head-support", "preload"],
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user