Skip to content

Track session access and modification in SessionMiddleware - #3166

Merged
Kludex merged 4 commits into
mainfrom
track-session-access-modification
Mar 1, 2026
Merged

Kludex merged 4 commits into
mainfrom
track-session-access-modification

Conversation

@Kludex

@Kludex Kludex commented Mar 1, 2026

Copy link
Copy Markdown
Owner

Summary

SessionMiddleware currently sends Set-Cookie on every response when the session is non-empty, even if nothing changed. This causes race conditions with concurrent requests - a slow read-only response can clobber a newer session cookie (see #2019, Chrome bug, authlib#334).

This PR replaces the plain dict used for scope["session"] with a Session subclass that tracks two flags:

  • accessed: set in HTTPConnection.session when the session is accessed, following Flask 3.1.3's approach of tracking at the property level rather than per-method overrides
  • modified: set on write operations (__setitem__, __delitem__, clear, update), with pop and setdefault conditionally marking modified only when the session actually changes (following Django and Flask/Werkzeug conventions)

The middleware then uses these flags to:

  • Only send Set-Cookie when modified is True
  • Add Vary: Cookie when accessed is True, so reverse proxies and CDNs don't cache session-dependent responses incorrectly

Kludex added 2 commits March 1, 2026 18:39
Replace the plain `dict` used for `scope["session"]` with a `Session`
subclass that tracks `accessed` and `modified` flags.

- Only send `Set-Cookie` when the session was actually modified, not on
  every response. This prevents race conditions with concurrent requests
  where a slow read-only response clobbers a newer cookie.
- Add `Vary: Cookie` header when the session is accessed, so reverse
  proxies and CDNs don't serve cached responses with session-dependent
  content to the wrong user.
- `pop` and `setdefault` conditionally set `modified` only when the
  session actually changes, following Django and Flask conventions.

Closes #2019
The dict_keys/dict_values/dict_items concrete types are not importable
in Python, so use typing.Any as the return type to satisfy mypy strict
mode without type: ignore comments.
@Kludex
Kludex force-pushed the track-session-access-modification branch 4 times, most recently from 078743c to cd7d3de Compare March 1, 2026 18:12
Move access tracking from per-method overrides (keys/values/items) on
the Session dict to the HTTPConnection.session property, matching
Flask 3.1.3's approach. This avoids the dict_keys/dict_values/dict_items
typing problem entirely since those types are not importable in Python.
@Kludex
Kludex force-pushed the track-session-access-modification branch from cd7d3de to dce3495 Compare March 1, 2026 18:13
@Kludex Kludex changed the title Track session access and modification in SessionMiddleware Track session access and modification in SessionMiddleware Mar 1, 2026
@Kludex
Kludex merged commit 4647e53 into main Mar 1, 2026
6 checks passed
@Kludex
Kludex deleted the track-session-access-modification branch March 1, 2026 18:21
maelgui added a commit to maelgui/bagadmenru-v5 that referenced this pull request Sep 12, 2026
…skey limité à beta

La création auto de passkey (conditional create post-login) réussissait
côté OS mais n'était jamais enregistrée serveur : le challenge WebAuthn
vit dans la session cookie, et SessionMiddleware (starlette < 1.0)
ré-émettait le Set-Cookie sur chaque réponse dont la session était non
vide. Pendant la rafale post-login, une réponse read-only portant un
cookie périmé écrasait le challenge entre /preregister et /register.
Credential orphelin dans le trousseau, 400 avalé par le catch silencieux.

Backend :
- starlette épinglé >= 1.6 : le middleware ne ré-émet le cookie que si
  la session a été MODIFIÉE (Kludex/starlette#3166) — les réponses
  read-only ne peuvent plus écraser un challenge fraîchement écrit,
  pour toutes les utilisations de session, pas seulement celle-ci
- tests de régression qui épinglent ce comportement (échouent sous
  0.46, vérifiés) pour qu'un downgrade ne réintroduise pas la course
- credential invalide -> 400 (au lieu de 500)
- le login mot de passe purge le auth_challenge résiduel du login
  conditionnel (hygiène : la session se vide, le cookie est supprimé)

Frontend :
- upgrade silencieux (conditional create) limité à beta et dev local via
  VITE_ENVIRONMENT, fail closed si absent — même patron que la debug bar
- un échec d'enregistrement APRÈS création du credential par l'OS n'est
  plus avalé : console.error + Sentry (le mode de défaillance qui
  fabrique des orphelins)

Tests : 203 unit + 4 migrations backend, 63 frontend, lint/mypy verts,
client OpenAPI inchangé.
maelgui added a commit to maelgui/bagadmenru-v5 that referenced this pull request Sep 12, 2026
…skey limité à beta

La création auto de passkey (conditional create post-login) réussissait
côté OS mais n'était jamais enregistrée serveur : le challenge WebAuthn
vit dans la session cookie, et SessionMiddleware (starlette < 1.0)
ré-émettait le Set-Cookie sur chaque réponse dont la session était non
vide. Pendant la rafale post-login, une réponse read-only portant un
cookie périmé écrasait le challenge entre /preregister et /register.
Credential orphelin dans le trousseau, 400 avalé par le catch silencieux.

Backend :
- starlette 0.46.2 -> 1.6.0 via poetry.lock (pas de pin pyproject :
  fastapi gère la contrainte et déclare >= 0.46 sans borne haute) : le
  middleware ne ré-émet le cookie que si la session a été MODIFIÉE
  (Kludex/starlette#3166) — les réponses read-only ne peuvent plus
  écraser un challenge fraîchement écrit, pour toutes les utilisations
  de session
- tests de régression qui épinglent ce comportement (échouent sous
  0.46, vérifiés) pour qu'un downgrade ne réintroduise pas la course
- credential invalide -> 400 (au lieu de 500)
- le login mot de passe purge le auth_challenge résiduel du login
  conditionnel (hygiène : la session se vide, le cookie est supprimé)

Frontend :
- upgrade silencieux (conditional create) limité à beta et dev local via
  VITE_ENVIRONMENT, fail closed si absent — même patron que la debug bar
- un échec d'enregistrement APRÈS création du credential par l'OS n'est
  plus avalé : console.error + Sentry (le mode de défaillance qui
  fabrique des orphelins)

Tests : 203 unit + 4 migrations backend, 63 frontend, lint/mypy verts,
client OpenAPI inchangé.
maelgui added a commit to maelgui/bagadmenru-v5 that referenced this pull request Sep 12, 2026
…skey limité à beta

La création auto de passkey (conditional create post-login) réussissait
côté OS mais n'était jamais enregistrée serveur : le challenge WebAuthn
vit dans la session cookie, et SessionMiddleware (starlette < 1.0)
ré-émettait le Set-Cookie sur chaque réponse dont la session était non
vide. Pendant la rafale post-login, une réponse read-only portant un
cookie périmé écrasait le challenge entre /preregister et /register.
Credential orphelin dans le trousseau, 400 avalé par le catch silencieux.

Backend :
- starlette 0.46.2 -> 1.6.0 via poetry.lock (pas de pin pyproject :
  fastapi gère la contrainte et déclare >= 0.46 sans borne haute) : le
  middleware ne ré-émet le cookie que si la session a été MODIFIÉE
  (Kludex/starlette#3166) — les réponses read-only ne peuvent plus
  écraser un challenge fraîchement écrit, pour toutes les utilisations
  de session
- tests de régression qui épinglent ce comportement (échouent sous
  0.46, vérifiés) pour qu'un downgrade ne réintroduise pas la course
- credential invalide -> 400 (au lieu de 500)
- le login mot de passe purge le auth_challenge résiduel du login
  conditionnel (hygiène : la session se vide, le cookie est supprimé)

Frontend :
- upgrade silencieux (conditional create) limité à beta et dev local via
  VITE_ENVIRONMENT, fail closed si absent — même patron que la debug bar
- un échec d'enregistrement APRÈS création du credential par l'OS n'est
  plus avalé : console.error + Sentry (le mode de défaillance qui
  fabrique des orphelins)

Tests : 203 unit + 4 migrations backend, 63 frontend, lint/mypy verts,
client OpenAPI inchangé.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SessionMiddleware sends a new set-cookie for every request, with unintended results

1 participant