Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions changelog.d/297.security.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
**Responses that carry a session token are never cacheable.** When
`FastAPICacheXSessionMiddleware` sends a token (a new session, a sliding
renewal, a regenerated ID) or a cookie-clearing `Set-Cookie`, it now sets
`Cache-Control: private, no-store`, replacing whatever the route set, and adds
`Vary` for the token transport even when the handler never touched
`request.session`. Before, a `@cache(public=True)` route could return
`Cache-Control: public` with a valid session cookie, and a CDN or reverse proxy
could hand that cookie to the next visitors. The deprecated `SessionMiddleware`
does the same when it sends a token, and neither middleware repeats a `Vary`
name the response already has.
7 changes: 7 additions & 0 deletions docs/SESSION.md
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,13 @@ async def me(session=Depends(get_session)):
the headers checked in `token_source_priority` order (`header_name`, and `Authorization` when
bearer tokens are enabled) up to the one that carried the token. `Cookie` is added only when
no header carried a token, because only then is the cookie read.
- A response that carries a session token (a new session, a sliding renewal, a regenerated ID)
or a `Set-Cookie` that expires the session cookie is never cacheable. The middleware sets
`Cache-Control: private, no-store`, replacing whatever the route set (a `@cache(public=True)`
route included), and adds the same `Vary` names as above even when the handler never touched
`request.session`. Otherwise a CDN or reverse proxy could store the token and hand it to the
next visitor. Responses without a token keep their headers. The deprecated `SessionMiddleware`
does the same when it sends a token in its response header.

The cookie is always `HttpOnly`; `Secure`, `SameSite`, `Domain`, `Path` and `Max-Age` follow the
`cookie_*` settings.
Expand Down
75 changes: 63 additions & 12 deletions fastapi_cachex/session/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,43 @@ def _read_header_token(
return None, consulted


def _add_vary(headers: MutableHeaders, names: list[str]) -> None:
"""Add header names to ``Vary``, keeping the values already there.

Starlette's ``add_vary_header`` appends unconditionally, so names the
response already varies on (compared case-insensitively) are skipped, as
is everything when it already varies on ``*``.

Args:
headers: Mutable response headers to write to
names: Request header names the response depends on
"""
present = {
value.strip().lower()
for line in headers.getlist("vary")
for value in line.split(",")
}
if "*" in present:
return
for name in names:
if name.lower() not in present:
headers.add_vary_header(name)
present.add(name.lower())


def _forbid_storing(headers: MutableHeaders) -> None:
"""Keep a response that carries a session token out of every cache.

The token is a credential: a shared cache that stored the response would
hand it to the next visitor. This replaces any ``Cache-Control`` the route
set, ``public`` and ``max-age`` included.

Args:
headers: Mutable response headers to write to
"""
headers["Cache-Control"] = "private, no-store"


def _stash_session_manager(app: Any, manager: SessionManager) -> None:
"""Register the session manager on ``app.state`` for dependency injection.

Expand Down Expand Up @@ -254,12 +291,15 @@ async def dispatch(
if session is not None and session.session_id != loaded_session_id:
# The handler regenerated the session ID; a renewed token would
# name the deleted record, so send a token for the new ID.
response.headers[self.config.header_name] = (
self.session_manager.issue_token(session)
)
elif renewed_token is not None:
response_token: str | None = self.session_manager.issue_token(session)
else:
# Propagate renewed token to client so its JWT exp stays in sync
response.headers[self.config.header_name] = renewed_token
response_token = renewed_token

if response_token is not None:
response.headers[self.config.header_name] = response_token
_add_vary(response.headers, _read_header_token(request, self.config)[1])
_forbid_storing(response.headers)

return response

Expand Down Expand Up @@ -408,11 +448,7 @@ async def send_wrapper(message: Message) -> None:
backend_session, loaded_session_id, loaded_token, renewed_token
)

if session.accessed:
for name in vary_on:
headers.add_vary_header(name)

await self._persist(
sent_token = await self._persist(
session,
headers,
connection,
Expand All @@ -422,6 +458,11 @@ async def send_wrapper(message: Message) -> None:
from_header=from_header,
)

if session.accessed or sent_token:
_add_vary(headers, vary_on)
if sent_token:
_forbid_storing(headers)

await send(message)

await self.app(scope, receive, send_wrapper)
Expand All @@ -436,8 +477,13 @@ async def _persist( # noqa: PLR0913, PLR0917
fresh_token: str | None,
*,
from_header: bool,
) -> None:
"""Save, delete or renew the session according to what the request did to it."""
) -> bool:
"""Save, delete or renew the session according to what the request did to it.

Returns:
True if a token or a clearing cookie was written to the response
"""
sent_token = False
target = backend_session
if session.cleared and target is not None:
# clear() logs out, whatever the data held. Anything
Expand All @@ -448,6 +494,7 @@ async def _persist( # noqa: PLR0913, PLR0917
# Cookie transport: expire the cookie. A header-based
# client simply drops its now-dangling token.
headers.append("Set-Cookie", self._build_clear_cookie_header())
sent_token = True

if session.modified and (
session or (target is not None and target.user is not None)
Expand All @@ -467,16 +514,20 @@ async def _persist( # noqa: PLR0913, PLR0917
token_to_emit = new_token if from_header else cookie_token
if token_to_emit is not None:
self._emit_token(headers, token_to_emit, from_header=from_header)
sent_token = True
elif session.modified and target is not None:
# An anonymous session left empty holds nothing to keep.
await self.session_manager.delete_session(target.session_id)
if not from_header:
headers.append("Set-Cookie", self._build_clear_cookie_header())
sent_token = True
elif fresh_token is not None:
# Sliding expiration renewed the token, or the ID was
# regenerated, even though the dict itself was untouched;
# propagate it via the same transport.
self._emit_token(headers, fresh_token, from_header=from_header)
sent_token = True
return sent_token

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

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

Expand Down
Loading
Loading