From cd01daec6dfce22597d3b9558d1a0770aa5b6290 Mon Sep 17 00:00:00 2001 From: Petru Paler Date: Thu, 8 Jan 2026 16:01:30 +0000 Subject: [PATCH] Fix CSRF cookie not being set on HTML responses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/animaltrack/web/app.py | 105 ++++++++++++++++++++++++------------- 1 file changed, 70 insertions(+), 35 deletions(-) diff --git a/src/animaltrack/web/app.py b/src/animaltrack/web/app.py index 2f10e8d..74e8b49 100644 --- a/src/animaltrack/web/app.py +++ b/src/animaltrack/web/app.py @@ -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