[loop-generated] [bug] CSRF middleware executes endpoint before checking @csrf_exempt — side effects bypass protection #626

Closed
opened 2026-03-20 20:38:40 +00:00 by Timmy · 1 comment
Owner

Bug

In src/dashboard/middleware/csrf.py, the dispatch() method has a security flaw.

When CSRF token validation fails AND the path is not in the exempt list, the middleware calls await call_next(request) (line 180) to execute the endpoint handler before checking if the endpoint is decorated with @csrf_exempt.

This means:

  1. The endpoint's side effects (database writes, emails, state changes) execute
  2. Only THEN does the middleware check for @csrf_exempt
  3. If the endpoint is NOT exempt, a 403 is returned — but the damage is done

Fix

The @csrf_exempt check should be done via route inspection BEFORE calling call_next(), or the middleware should collect decorated endpoints at startup.

File

src/dashboard/middleware/csrf.pydispatch() method, lines 178-197

## Bug In `src/dashboard/middleware/csrf.py`, the `dispatch()` method has a security flaw. When CSRF token validation fails AND the path is not in the exempt list, the middleware calls `await call_next(request)` (line 180) to execute the endpoint handler **before** checking if the endpoint is decorated with `@csrf_exempt`. This means: 1. The endpoint's side effects (database writes, emails, state changes) execute 2. Only THEN does the middleware check for `@csrf_exempt` 3. If the endpoint is NOT exempt, a 403 is returned — but the damage is done ### Fix The `@csrf_exempt` check should be done via route inspection BEFORE calling `call_next()`, or the middleware should collect decorated endpoints at startup. ### File `src/dashboard/middleware/csrf.py` — `dispatch()` method, lines 178-197
Author
Owner

Kimi: The fix is more nuanced than I initially thought. In Starlette, request.scope['endpoint'] is only populated AFTER the router processes the request (inside call_next()). So the middleware cannot check @csrf_exempt before calling call_next().

The correct fix:

  1. In CSRFMiddleware.__init__(), after super().__init__(app), walk the app's routes to collect all @csrf_exempt endpoint functions into a self._exempt_endpoints: set (store them by id or the function object itself).

  2. In _is_likely_exempt(), also accept the request and try to match the path against registered routes to find the endpoint. Use from starlette.routing import Match — iterate self.app.app.routes and call route.matches(request.scope).

  3. Alternatively, simpler approach: have @csrf_exempt register the function into a module-level _exempt_routes set (which already exists but is unused at line 18!). Then in dispatch(), check both the path exemptions AND the registered functions set.

Simplest approach — use the existing _exempt_routes set:

  • @csrf_exempt already exists. Modify it to also add the route path to _exempt_routes.
  • But we don't know the route path at decoration time...

Actually simplest approach:

  • Keep the current code structure but wrap call_next() carefully: the endpoint IS resolved by Starlette before the response body is generated, because Starlette's ServerErrorMiddleware populates scope['endpoint'] during routing.
  • The real issue is that response = await call_next(request) DOES execute the handler.
  • The fix: use request.scope inspection. Starlette populates the endpoint in scope during the routing phase (before handler execution). Test if a lightweight route-resolution pass can be done.

Pragmatic fix: Since _exempt_routes exists but is unused, register exempt paths there at app startup. In dispatch(), check _exempt_routes alongside _is_likely_exempt(). This avoids the Starlette scope timing issue entirely.

Files: src/dashboard/middleware/csrf.py
Tests: tests/dashboard/middleware/test_csrf_decorator_support.py (update tests to verify no side effects on CSRF rejection)
Run: tox -e unit

Kimi: The fix is more nuanced than I initially thought. In Starlette, `request.scope['endpoint']` is only populated AFTER the router processes the request (inside `call_next()`). So the middleware cannot check `@csrf_exempt` before calling `call_next()`. **The correct fix:** 1. In `CSRFMiddleware.__init__()`, after `super().__init__(app)`, walk the app's routes to collect all `@csrf_exempt` endpoint functions into a `self._exempt_endpoints: set` (store them by id or the function object itself). 2. In `_is_likely_exempt()`, also accept the `request` and try to match the path against registered routes to find the endpoint. Use `from starlette.routing import Match` — iterate `self.app.app.routes` and call `route.matches(request.scope)`. 3. Alternatively, simpler approach: have `@csrf_exempt` register the function into a module-level `_exempt_routes` set (which already exists but is unused at line 18!). Then in `dispatch()`, check both the path exemptions AND the registered functions set. **Simplest approach — use the existing `_exempt_routes` set:** - `@csrf_exempt` already exists. Modify it to also add the route path to `_exempt_routes`. - But we don't know the route path at decoration time... **Actually simplest approach:** - Keep the current code structure but wrap `call_next()` carefully: the endpoint IS resolved by Starlette before the response body is generated, because Starlette's ServerErrorMiddleware populates `scope['endpoint']` during routing. - The real issue is that `response = await call_next(request)` DOES execute the handler. - The fix: use `request.scope` inspection. Starlette populates the endpoint in scope during the routing phase (before handler execution). Test if a lightweight route-resolution pass can be done. **Pragmatic fix:** Since `_exempt_routes` exists but is unused, register exempt paths there at app startup. In `dispatch()`, check `_exempt_routes` alongside `_is_likely_exempt()`. This avoids the Starlette scope timing issue entirely. Files: `src/dashboard/middleware/csrf.py` Tests: `tests/dashboard/middleware/test_csrf_decorator_support.py` (update tests to verify no side effects on CSRF rejection) Run: `tox -e unit`
kimi was assigned by Timmy 2026-03-20 20:40:40 +00:00
kimi was unassigned by Timmy 2026-03-20 22:59:52 +00:00
Timmy self-assigned this 2026-03-20 22:59:52 +00:00
Timmy closed this issue 2026-03-20 23:05:10 +00:00
Sign in to join this conversation.
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: Rockachopa/Timmy-time-dashboard#626