Skip to content

Commit 1f7f9fb

Browse files
committed
fix(session): keep responses that carry a session token out of shared caches
When FastAPICacheXSessionMiddleware sent a token (new session, sliding renewal, regenerated ID) or a clearing cookie, nothing stopped a CDN or reverse proxy from storing it: Vary was only added when the handler accessed request.session and Cache-Control was left as the route set it, so a @cache(public=True) route could hand a valid session cookie to the next visitor. Such responses now get Cache-Control: private, no-store (replacing any existing value) and Vary on the transport headers, whatever the handler did with the session. The deprecated SessionMiddleware does the same when it sends a token. Vary names already present are no longer duplicated. Closes #297
1 parent 7079b75 commit 1f7f9fb

4 files changed

Lines changed: 398 additions & 12 deletions

File tree

‎docs/SESSION.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -411,6 +411,13 @@ async def me(session=Depends(get_session)):
411411
the headers checked in `token_source_priority` order (`header_name`, and `Authorization` when
412412
bearer tokens are enabled) up to the one that carried the token. `Cookie` is added only when
413413
no header carried a token, because only then is the cookie read.
414+
- A response that carries a session token (a new session, a sliding renewal, a regenerated ID)
415+
or a `Set-Cookie` that expires the session cookie is never cacheable. The middleware sets
416+
`Cache-Control: private, no-store`, replacing whatever the route set (a `@cache(public=True)`
417+
route included), and adds the same `Vary` names as above even when the handler never touched
418+
`request.session`. Otherwise a CDN or reverse proxy could store the token and hand it to the
419+
next visitor. Responses without a token keep their headers. The deprecated `SessionMiddleware`
420+
does the same when it sends a token in its response header.
414421

415422
The cookie is always `HttpOnly`; `Secure`, `SameSite`, `Domain`, `Path` and `Max-Age` follow the
416423
`cookie_*` settings.

‎fastapi_cachex/session/middleware.py‎

Lines changed: 63 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,43 @@ def _read_header_token(
146146
return None, consulted
147147

148148

149+
def _add_vary(headers: MutableHeaders, names: list[str]) -> None:
150+
"""Add header names to ``Vary``, keeping the values already there.
151+
152+
Starlette's ``add_vary_header`` appends unconditionally, so names the
153+
response already varies on (compared case-insensitively) are skipped, as
154+
is everything when it already varies on ``*``.
155+
156+
Args:
157+
headers: Mutable response headers to write to
158+
names: Request header names the response depends on
159+
"""
160+
present = {
161+
value.strip().lower()
162+
for line in headers.getlist("vary")
163+
for value in line.split(",")
164+
}
165+
if "*" in present:
166+
return
167+
for name in names:
168+
if name.lower() not in present:
169+
headers.add_vary_header(name)
170+
present.add(name.lower())
171+
172+
173+
def _forbid_storing(headers: MutableHeaders) -> None:
174+
"""Keep a response that carries a session token out of every cache.
175+
176+
The token is a credential: a shared cache that stored the response would
177+
hand it to the next visitor. This replaces any ``Cache-Control`` the route
178+
set, ``public`` and ``max-age`` included.
179+
180+
Args:
181+
headers: Mutable response headers to write to
182+
"""
183+
headers["Cache-Control"] = "private, no-store"
184+
185+
149186
def _stash_session_manager(app: Any, manager: SessionManager) -> None:
150187
"""Register the session manager on ``app.state`` for dependency injection.
151188
@@ -254,12 +291,15 @@ async def dispatch(
254291
if session is not None and session.session_id != loaded_session_id:
255292
# The handler regenerated the session ID; a renewed token would
256293
# name the deleted record, so send a token for the new ID.
257-
response.headers[self.config.header_name] = (
258-
self.session_manager.issue_token(session)
259-
)
260-
elif renewed_token is not None:
294+
response_token: str | None = self.session_manager.issue_token(session)
295+
else:
261296
# Propagate renewed token to client so its JWT exp stays in sync
262-
response.headers[self.config.header_name] = renewed_token
297+
response_token = renewed_token
298+
299+
if response_token is not None:
300+
response.headers[self.config.header_name] = response_token
301+
_add_vary(response.headers, _read_header_token(request, self.config)[1])
302+
_forbid_storing(response.headers)
263303

264304
return response
265305

@@ -408,11 +448,7 @@ async def send_wrapper(message: Message) -> None:
408448
backend_session, loaded_session_id, loaded_token, renewed_token
409449
)
410450

411-
if session.accessed:
412-
for name in vary_on:
413-
headers.add_vary_header(name)
414-
415-
await self._persist(
451+
sent_token = await self._persist(
416452
session,
417453
headers,
418454
connection,
@@ -422,6 +458,11 @@ async def send_wrapper(message: Message) -> None:
422458
from_header=from_header,
423459
)
424460

461+
if session.accessed or sent_token:
462+
_add_vary(headers, vary_on)
463+
if sent_token:
464+
_forbid_storing(headers)
465+
425466
await send(message)
426467

427468
await self.app(scope, receive, send_wrapper)
@@ -436,8 +477,13 @@ async def _persist( # noqa: PLR0913, PLR0917
436477
fresh_token: str | None,
437478
*,
438479
from_header: bool,
439-
) -> None:
440-
"""Save, delete or renew the session according to what the request did to it."""
480+
) -> bool:
481+
"""Save, delete or renew the session according to what the request did to it.
482+
483+
Returns:
484+
True if a token or a clearing cookie was written to the response
485+
"""
486+
sent_token = False
441487
target = backend_session
442488
if session.cleared and target is not None:
443489
# clear() logs out, whatever the data held. Anything
@@ -448,6 +494,7 @@ async def _persist( # noqa: PLR0913, PLR0917
448494
# Cookie transport: expire the cookie. A header-based
449495
# client simply drops its now-dangling token.
450496
headers.append("Set-Cookie", self._build_clear_cookie_header())
497+
sent_token = True
451498

452499
if session.modified and (
453500
session or (target is not None and target.user is not None)
@@ -467,16 +514,20 @@ async def _persist( # noqa: PLR0913, PLR0917
467514
token_to_emit = new_token if from_header else cookie_token
468515
if token_to_emit is not None:
469516
self._emit_token(headers, token_to_emit, from_header=from_header)
517+
sent_token = True
470518
elif session.modified and target is not None:
471519
# An anonymous session left empty holds nothing to keep.
472520
await self.session_manager.delete_session(target.session_id)
473521
if not from_header:
474522
headers.append("Set-Cookie", self._build_clear_cookie_header())
523+
sent_token = True
475524
elif fresh_token is not None:
476525
# Sliding expiration renewed the token, or the ID was
477526
# regenerated, even though the dict itself was untouched;
478527
# propagate it via the same transport.
479528
self._emit_token(headers, fresh_token, from_header=from_header)
529+
sent_token = True
530+
return sent_token
480531

481532
def _token_sources(
482533
self, connection: HTTPConnection

‎i18n/zh-TW/docs/SESSION.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,7 @@ async def me(session=Depends(get_session)):
348348
- 以 `del` 或 `pop()` 移除最後一個鍵並不是登出。帶有使用者的 Session 會以空資料儲存;匿名 Session 已無任何內容,會和 `clear()` 一樣被刪除。
349349
- 以寫入 `request.session` 的方式登入時,會沿用請求帶來的 Session ID。Starlette 的中介軟體中 Cookie *就是* Session,因此登入回應會取代任何被植入的 Cookie;這裡的 Cookie 只是指向伺服器端紀錄的名稱,被植入的 Cookie 會跟著受害者一起登入。請在附加使用者之前呼叫 `await rotate_session_id(request)`(見[登入後重新產生 Session ID](#5-regenerate-the-session-id-after-login))。
350350
- 只要存取 `request.session`,就會為了尋找權杖而讀取過的每個請求標頭加入 `Vary`:依 `token_source_priority` 順序檢查的標頭(`header_name`,以及啟用 Bearer 權杖時的 `Authorization`),直到攜帶權杖的那一個為止。只有在沒有任何標頭攜帶權杖時才會讀取 Cookie,因此也只有這時才會加入 `Cookie`。
351+
- 帶有 Session 權杖的回應(新建立的 Session、滑動續期、重新產生的 ID),或帶有讓 Session Cookie 失效之 `Set-Cookie` 的回應,一律不可快取。中介軟體會設定 `Cache-Control: private, no-store`,取代路由原本設定的值(包括 `@cache(public=True)` 的路由),並且即使處理函式沒有碰過 `request.session`,也會加入與上一項相同的 `Vary` 名稱。否則 CDN 或反向 proxy 可能存下權杖,再交給下一位訪客。不帶權杖的回應則維持原本的標頭。已棄用的 `SessionMiddleware` 在回應標頭送出權杖時也會這麼做。
351352

352353
Cookie 一律為 `HttpOnly`;`Secure`、`SameSite`、`Domain`、`Path` 與 `Max-Age` 則依 `cookie_*` 設定。
353354

0 commit comments

Comments
 (0)