From 4312fd9249a9e81c15255e3fe98de1c55a5aedb9 Mon Sep 17 00:00:00 2001 From: allen0099 Date: Wed, 30 Sep 2026 06:20:05 +0000 Subject: [PATCH] feat(session)!: default the session cookie to __Host-session with Secure SessionConfig.cookie_name defaults to "__Host-session" and cookie_https_only to True. Browsers accept a __Host- cookie only when it is Secure, has Path=/ and no Domain, and never from a subdomain, which removes the usual way to plant a session cookie. A __Host- name without Secure, with a cookie_path other than "/" or with a cookie_domain, and a __Secure- name without Secure, now raise a ValidationError instead of the 0.3.9 UserWarning. The message says when the name is the default and suggests a fix that fits the problem. The SameSite=None warning no longer fires for a prefixed name, which the prefix check already rejects. The middleware's FutureWarning about the cookie defaults is removed. BREAKING CHANGE: the session cookie is renamed to __Host-session and is Secure by default, so every cookie session is logged out once after the upgrade and the cookie is not sent over plain HTTP. For plain-HTTP development, set cookie_name="session", cookie_https_only=False. Setting only cookie_https_only=False, cookie_path or cookie_domain with the default name now raises. Refs #256 --- changelog.d/256.changed.md | 12 +++ docs/MIGRATING_0_4.md | 4 +- docs/SESSION.md | 23 ++-- examples/session_api.py | 5 +- examples/session_jwt.py | 6 +- examples/session_jwt_claims.py | 3 +- examples/session_login.py | 7 +- examples/session_redis.py | 3 +- fastapi_cachex/session/config.py | 49 ++++++--- fastapi_cachex/session/middleware.py | 39 +------ i18n/zh-TW/docs/MIGRATING_0_4.md | 4 +- i18n/zh-TW/docs/SESSION.md | 23 ++-- tests/session/test_0_4_0_notices.py | 119 --------------------- tests/session/test_config.py | 98 ++++++++++++++++- tests/session/test_starlette_middleware.py | 35 +++++- 15 files changed, 211 insertions(+), 219 deletions(-) create mode 100644 changelog.d/256.changed.md diff --git a/changelog.d/256.changed.md b/changelog.d/256.changed.md new file mode 100644 index 0000000..c38f518 --- /dev/null +++ b/changelog.d/256.changed.md @@ -0,0 +1,12 @@ +**The session cookie defaults to `__Host-session` with the `Secure` flag, and +`SessionConfig` rejects cookie settings browsers would refuse.** `SessionConfig.cookie_name` +defaults to `"__Host-session"` and `cookie_https_only` to `True`, so a planted +cookie from a subdomain or over plain HTTP is refused by the browser. The new +name logs every cookie session out once after the upgrade. For plain-HTTP +development, set `cookie_name="session", cookie_https_only=False`. A +`__Host-` name without `Secure`, with a `cookie_path` other than `"/"` or with +a `cookie_domain`, and a `__Secure-` name without `Secure`, raise a +`ValidationError` instead of a `UserWarning`; because of the new default name, +so does changing only one of those settings. The middleware's `FutureWarning` +about the defaults is removed. See the +[migration guide](https://fastapi-cachex.readthedocs.io/en/stable/MIGRATING_0_4/#session-cookie). diff --git a/docs/MIGRATING_0_4.md b/docs/MIGRATING_0_4.md index d570333..e16b561 100644 --- a/docs/MIGRATING_0_4.md +++ b/docs/MIGRATING_0_4.md @@ -27,7 +27,7 @@ Every warning below names the setting to change and links to its issue. `FutureW | Change | Issue | Warned in 0.3.9 | Section | |--------|-------|-----------------|---------| | Session cookie defaults to `__Host-session` with `Secure` | [#256](https://github.com/allen0099/FastAPI-CacheX/issues/256) | `FutureWarning` | [Session cookie](#session-cookie) | -| Contradictory `__Host-` / `__Secure-` cookie settings are rejected | [#256](https://github.com/allen0099/FastAPI-CacheX/issues/256) | `UserWarning` | [Session cookie](#session-cookie) | +| Contradictory `__Host-` / `__Secure-` cookie settings are rejected | [#256](https://github.com/allen0099/FastAPI-CacheX/issues/256) | `UserWarning` (`FutureWarning` when `cookie_name` is left at its default, under the middleware only) | [Session cookie](#session-cookie) | | Explicit `login()` / `logout()`, read-only `Session.user` | [#256](https://github.com/allen0099/FastAPI-CacheX/issues/256) | No | [Login and logout](#login-logout) | | `get_or_set()` locks by default | [#280](https://github.com/allen0099/FastAPI-CacheX/issues/280) | `FutureWarning` | [get_or_set lock](#get-or-set-lock) | | `get_session_manager` resolves through `SessionManagerProxy` | [#131](https://github.com/allen0099/FastAPI-CacheX/issues/131) | `FutureWarning` | [get_session_manager](#get-session-manager) | @@ -83,7 +83,7 @@ config = SessionConfig( ) ``` -0.4.0 also rejects contradictory settings: a `__Host-` name without `cookie_https_only=True`, with a `cookie_path` other than `"/"` or with a `cookie_domain`, and a `__Secure-` name without `cookie_https_only=True`. Browsers already refuse such a cookie, so the session never sticks; 0.3.9 emits a `UserWarning` when `SessionConfig` is built with one of them. +0.4.0 also rejects contradictory settings: a `__Host-` name without `cookie_https_only=True`, with a `cookie_path` other than `"/"` or with a `cookie_domain`, and a `__Secure-` name without `cookie_https_only=True`. Browsers already refuse such a cookie, so the session never sticks; 0.3.9 emits a `UserWarning` when `SessionConfig` is built with one of them. The rejection is a `pydantic.ValidationError` (a `ValueError`) from `SessionConfig`. Because the default name is now `__Host-session`, a config that sets only `cookie_https_only=False`, a `cookie_path` or a `cookie_domain` and leaves `cookie_name` at its default raises too. 0.3.9 warned about that only under `FastAPICacheXSessionMiddleware`, with the `FutureWarning` above; a header-only setup that sets one of these options got no warning. Set `cookie_name` as well, as in the first After example, or remove the option if nothing reads the cookie. ### Login and logout {#login-logout} diff --git a/docs/SESSION.md b/docs/SESSION.md index 2a23ea5..daa9d68 100644 --- a/docs/SESSION.md +++ b/docs/SESSION.md @@ -16,7 +16,7 @@ The header-only `SessionMiddleware`, deprecated since 0.3.1, was **removed in 0. The six `cookie_*` settings of `SessionConfig` (`cookie_name`, `cookie_max_age`, `cookie_path`, `cookie_same_site`, `cookie_https_only`, `cookie_domain`) are **read only by `FastAPICacheXSessionMiddleware`**; setting them has no effect when `SessionManager` is used -without it. +without it, although `SessionConfig` still validates them (see [Cookie defaults](#cookie-defaults)). Complete runnable examples: [`examples/session_login.py`](https://github.com/allen0099/FastAPI-CacheX/blob/master/examples/session_login.py) and [`examples/session_jwt.py`](https://github.com/allen0099/FastAPI-CacheX/blob/master/examples/session_jwt.py). @@ -249,28 +249,25 @@ SessionConfig( # Backend backend_key_prefix="session:", # Cookies (read only by FastAPICacheXSessionMiddleware) - cookie_name="session", # "__Host-session" from 0.4.0 + cookie_name="__Host-session", # see "Cookie defaults" below cookie_max_age=14 * 24 * 60 * 60, # None = no Max-Age (cookie ends with the browser session) cookie_path="/", cookie_same_site="lax", # "lax" / "strict" / "none" ("none" needs cookie_https_only=True) - cookie_https_only=False, # True adds the Secure flag; True from 0.4.0 + cookie_https_only=True, # the Secure flag: the cookie is only sent over HTTPS cookie_domain=None, # None = no Domain attribute ) ``` -#### Cookie defaults change in 0.4.0 {#cookie-defaults-change-in-040} +#### Cookie defaults {#cookie-defaults} -0.4.0 names the session cookie `__Host-session` and sets the `Secure` flag by default. Browsers accept a `__Host-` cookie only when it is `Secure`, has `Path=/` and no `Domain`, and never from a subdomain, which removes the usual way to plant a session cookie (session fixation). The new name also means every browser holding a `session` cookie is logged out once after the upgrade. +The session cookie is named `__Host-session` and carries the `Secure` flag by default. Browsers accept a `__Host-` cookie only when it is `Secure`, has `Path=/` and no `Domain`, and never from a subdomain, which removes the usual way to plant a session cookie (session fixation). -Until then, `FastAPICacheXSessionMiddleware` emits a `FutureWarning` when it is constructed (when the app builds its middleware stack, at startup or on the first request) and its config leaves `cookie_name` or `cookie_https_only` at the default. Set both to silence it: +A `Secure` cookie is not sent over plain HTTP. For local development without TLS, name the cookie without the prefix and drop the flag: `cookie_name="session", cookie_https_only=False`. -- `cookie_name="session", cookie_https_only=False` keeps the current cookie (and keeps working after the upgrade, e.g. for local development over plain HTTP); -- `cookie_name="__Host-session", cookie_https_only=True` switches now, over HTTPS. - -A `__Host-` name with `cookie_https_only=False`, a `cookie_path` other than `/` or a `cookie_domain` (and a `__Secure-` name without `cookie_https_only=True`) emits a `UserWarning`, because browsers refuse such a cookie; 0.4.0 rejects the combination. See [Migrating to 0.4.0](MIGRATING_0_4.md#session-cookie). +`SessionConfig` raises a `ValidationError` for a cookie browsers would refuse: a `__Host-` name with `cookie_https_only=False`, a `cookie_path` other than `/` or a `cookie_domain`, and a `__Secure-` name without `cookie_https_only=True`. The default name has the `__Host-` prefix, so changing only one of those settings raises too; change `cookie_name` with it. Upgrading from 0.3.x changes the cookie name, which logs every cookie session out once; see [Migrating to 0.4.0](MIGRATING_0_4.md#session-cookie). Sessions expire after `session_ttl` seconds. With `sliding_expiration`, each request that finds less than `session_ttl * sliding_threshold` seconds remaining extends the expiry to a full @@ -376,13 +373,13 @@ issued so far. ### 2. HTTPS Only -Always transport tokens over HTTPS in production. For cookie clients, mark the cookie `Secure`: +Always transport tokens over HTTPS in production. For cookie clients, keep the cookie `Secure`, as it is by default: ```python config = SessionConfig( secret_key="...", - cookie_name="__Host-session", # browsers refuse it without Secure, Path=/, no Domain - cookie_https_only=True, # adds the Secure flag to the session cookie + cookie_name="__Host-session", # the default; browsers refuse it without Secure, Path=/, no Domain + cookie_https_only=True, # the default; adds the Secure flag to the session cookie ) ``` diff --git a/examples/session_api.py b/examples/session_api.py index 55ceead..12c1cb2 100644 --- a/examples/session_api.py +++ b/examples/session_api.py @@ -58,8 +58,9 @@ def session_secret_key() -> str: # At least 32 characters, from the environment; see session_secret_key(). secret_key=session_secret_key(), session_ttl=3600, # 1 hour - # Both cookie defaults change in 0.4.0, so set them explicitly. Over HTTPS - # use cookie_name="__Host-session", cookie_https_only=True. + # The default cookie (__Host-session with the Secure flag) needs HTTPS. + # This example runs over plain HTTP, so it names the cookie without the + # __Host- prefix and drops Secure; in production, leave both out. cookie_name="session", cookie_https_only=False, ) diff --git a/examples/session_jwt.py b/examples/session_jwt.py index a7fee4e..81334a8 100644 --- a/examples/session_jwt.py +++ b/examples/session_jwt.py @@ -61,9 +61,9 @@ def session_secret_key() -> str: # Optional: issued as `iss`/`aud` and checked on every request. jwt_issuer="https://api.example.com", jwt_audience="example-clients", - # The middleware also accepts a cookie. 0.4.0 changes both cookie defaults - # (to "__Host-session" with the Secure flag), so set them explicitly; over - # HTTPS use cookie_name="__Host-session" and cookie_https_only=True. + # The middleware also accepts a cookie. The default one (__Host-session + # with the Secure flag) needs HTTPS; this example runs over plain HTTP, so + # it names the cookie without the __Host- prefix and drops Secure. cookie_name="session", cookie_https_only=False, ) diff --git a/examples/session_jwt_claims.py b/examples/session_jwt_claims.py index db9db8c..dff1301 100644 --- a/examples/session_jwt_claims.py +++ b/examples/session_jwt_claims.py @@ -159,8 +159,7 @@ def session_secret_key() -> str: jwt_algorithm="HS256", jwt_issuer="acme-corp", jwt_audience="acme-api", - cookie_name="__Host-session", # the middleware also reads a cookie - cookie_https_only=True, + # The middleware also reads a cookie: __Host-session with Secure by default. ) # token_serializer replaces the serializer chosen from token_format. diff --git a/examples/session_login.py b/examples/session_login.py index d06cfbc..5318330 100644 --- a/examples/session_login.py +++ b/examples/session_login.py @@ -59,10 +59,9 @@ def session_secret_key() -> str: # At least 32 characters, from the environment; see session_secret_key(). secret_key=session_secret_key(), session_ttl=3600, - # 0.4.0 changes both cookie defaults (to "__Host-session" with the Secure - # flag), so set them explicitly. In production over HTTPS use - # cookie_name="__Host-session" and cookie_https_only=True; keep False only - # for local HTTP development. + # The default cookie (__Host-session with the Secure flag) needs HTTPS. + # This example runs over plain HTTP, so it names the cookie without the + # __Host- prefix and drops Secure; in production, leave both out. cookie_name="session", cookie_https_only=False, ) diff --git a/examples/session_redis.py b/examples/session_redis.py index 7a2c4c6..cb408a6 100644 --- a/examples/session_redis.py +++ b/examples/session_redis.py @@ -73,8 +73,7 @@ def session_secret_key() -> str: sliding_threshold=0.5, ip_binding=True, # reject the token from another IP address user_agent_binding=False, # optional: reject it from another User-Agent - cookie_name="__Host-session", # the 0.4.0 default; needs HTTPS - cookie_https_only=True, + # The cookie defaults to __Host-session with the Secure flag: HTTPS only. ) session_manager = SessionManager(backend, config) # From 0.4.0 ClientIPDep resolves the manager only through the proxy. diff --git a/fastapi_cachex/session/config.py b/fastapi_cachex/session/config.py index bd6c9ed..1900908 100644 --- a/fastapi_cachex/session/config.py +++ b/fastapi_cachex/session/config.py @@ -170,7 +170,7 @@ class SessionConfig(BaseModel): # Cookie settings (FastAPICacheXSessionMiddleware only) cookie_name: str = Field( - default="session", + default="__Host-session", description="Name of the cookie used to store the session token " "(FastAPICacheXSessionMiddleware only)", ) @@ -188,7 +188,7 @@ class SessionConfig(BaseModel): description="SameSite attribute for the session cookie", ) cookie_https_only: bool = Field( - default=False, + default=True, description="Whether to set the Secure flag on the session cookie " "(cookie only sent over HTTPS)", ) @@ -238,9 +238,15 @@ def _warn_insecure_same_site_none(self) -> "SessionConfig": """Warn about a SameSite=None cookie without the Secure flag. Browsers drop such a cookie, so the session would silently never stick. - Rejecting the combination would break existing configurations. + Rejecting the combination would break existing configurations. A + ``__Host-`` / ``__Secure-`` name without Secure is rejected by + ``_check_cookie_prefix`` instead, so it is not warned about twice. """ - if self.cookie_same_site == "none" and not self.cookie_https_only: + if ( + self.cookie_same_site == "none" + and not self.cookie_https_only + and not self.cookie_name.startswith(("__Host-", "__Secure-")) + ): warnings.warn( 'cookie_same_site="none" requires cookie_https_only=True: browsers ' "reject a SameSite=None cookie without the Secure flag, so the " @@ -251,13 +257,12 @@ def _warn_insecure_same_site_none(self) -> "SessionConfig": return self @model_validator(mode="after") - def _warn_invalid_cookie_prefix(self) -> "SessionConfig": - """Warn about a ``__Host-`` / ``__Secure-`` cookie browsers will refuse. + def _check_cookie_prefix(self) -> "SessionConfig": + """Reject a ``__Host-`` / ``__Secure-`` cookie browsers would refuse. Browsers store a ``__Secure-`` cookie only with the Secure flag, and a ``__Host-`` cookie only with Secure, ``Path=/`` and no ``Domain``, so - the session would silently never stick. 0.4.0 rejects these - combinations (#256). + the session would silently never stick (#256). """ problems: list[str] = [] if self.cookie_name.startswith(("__Host-", "__Secure-")): @@ -269,15 +274,27 @@ def _warn_invalid_cookie_prefix(self) -> "SessionConfig": if self.cookie_domain is not None: problems.append("cookie_domain=None") if problems: - warnings.warn( - f"cookie_name={self.cookie_name!r} requires {', '.join(problems)}: " - "browsers refuse a cookie with this prefix otherwise, so the " - "session cookie would never be stored. Version 0.4.0 will reject " - "this configuration " - "(https://github.com/allen0099/FastAPI-CacheX/issues/256).", - UserWarning, - stacklevel=3, + name = f"cookie_name={self.cookie_name!r}" + if "cookie_name" not in self.model_fields_set: + name += " (the default)" + hints: list[str] = [] + if "cookie_https_only=True" in problems: + hints.append( + "For plain-HTTP development, use a name without the prefix, " + "such as cookie_name='session' with cookie_https_only=False." + ) + if self.cookie_path != "/" or self.cookie_domain is not None: + hints.append( + "To set a cookie_path or cookie_domain, use " + "cookie_name='__Secure-session', which keeps the Secure flag." + ) + msg = ( + f"{name} requires {', '.join(problems)}: browsers refuse a cookie " + "with this prefix otherwise, so the session cookie would never be " + f"stored. {' '.join(hints)} See " + "https://fastapi-cachex.readthedocs.io/en/stable/SESSION/#cookie-defaults" ) + raise ValueError(msg) return self @field_validator("token_source_priority") diff --git a/fastapi_cachex/session/middleware.py b/fastapi_cachex/session/middleware.py index a335848..3c595e5 100644 --- a/fastapi_cachex/session/middleware.py +++ b/fastapi_cachex/session/middleware.py @@ -163,38 +163,6 @@ def _stash_session_manager(app: Any, manager: SessionManager) -> None: setattr(app.state, "__fastapi_cachex_session_manager", manager) -def _warn_if_default_cookie(config: SessionConfig) -> None: - """Warn that 0.4.0 changes the session cookie defaults this config relies on. - - 0.4.0 names the cookie ``__Host-session`` and sets the Secure flag by - default (#256). The new name logs every cookie session out once, and the - Secure flag stops the cookie working over plain HTTP, so both have to be - chosen explicitly to be unaffected. - - Args: - config: The configuration the middleware uses - """ - defaults = [ - name - for name in ("cookie_name", "cookie_https_only") - if name not in config.model_fields_set - ] - if not defaults: - return - warnings.warn( - f"FastAPICacheXSessionMiddleware is using the default {' and '.join(defaults)} " - "of SessionConfig. Version 0.4.0 changes the session cookie defaults to " - "cookie_name='__Host-session' with the Secure flag (cookie_https_only=True): " - "the new name logs every cookie session out once on upgrade, and a Secure " - "cookie is not sent over plain HTTP. Set both explicitly: " - "cookie_name='session', cookie_https_only=False keeps the current cookie; " - "cookie_name='__Host-session', cookie_https_only=True switches now " - "(https://github.com/allen0099/FastAPI-CacheX/issues/256).", - FutureWarning, - stacklevel=3, - ) - - def _warn_if_priority_without_cookie(config: SessionConfig) -> None: """Warn that 0.4.0 stops using the cookie for a list without ``"cookie"``. @@ -279,15 +247,12 @@ def __init__( ``SessionManagerProxy`` holds none. Warns: - FutureWarning: If the configuration leaves ``cookie_name`` or - ``cookie_https_only`` at its default, both of which change in - 0.4.0, or sets ``token_source_priority`` without ``"cookie"``, - which disables the cookie in 0.4.0. + FutureWarning: If the configuration sets ``token_source_priority`` + without ``"cookie"``, which disables the cookie in 0.4.0. """ self.app = app self.session_manager = session_manager or SessionManagerProxy.get() self.config = config or self.session_manager.config - _warn_if_default_cookie(self.config) _warn_if_priority_without_cookie(self.config) security_flags = f"httponly; samesite={self.config.cookie_same_site}" diff --git a/i18n/zh-TW/docs/MIGRATING_0_4.md b/i18n/zh-TW/docs/MIGRATING_0_4.md index 0f6379a..073378b 100644 --- a/i18n/zh-TW/docs/MIGRATING_0_4.md +++ b/i18n/zh-TW/docs/MIGRATING_0_4.md @@ -27,7 +27,7 @@ filterwarnings = [ | 變更 | Issue | 0.3.9 是否警告 | 章節 | |------|-------|----------------|------| | Session Cookie 預設為 `__Host-session` 並帶 `Secure` | [#256](https://github.com/allen0099/FastAPI-CacheX/issues/256) | `FutureWarning` | [Session Cookie](#session-cookie) | -| 拒絕互相矛盾的 `__Host-`/`__Secure-` Cookie 設定 | [#256](https://github.com/allen0099/FastAPI-CacheX/issues/256) | `UserWarning` | [Session Cookie](#session-cookie) | +| 拒絕互相矛盾的 `__Host-`/`__Secure-` Cookie 設定 | [#256](https://github.com/allen0099/FastAPI-CacheX/issues/256) | `UserWarning`(`cookie_name` 維持預設值時為 `FutureWarning`,僅限使用中介軟體時) | [Session Cookie](#session-cookie) | | 明確的 `login()`/`logout()`、唯讀的 `Session.user` | [#256](https://github.com/allen0099/FastAPI-CacheX/issues/256) | 否 | [登入與登出](#login-logout) | | `get_or_set()` 預設使用鎖 | [#280](https://github.com/allen0099/FastAPI-CacheX/issues/280) | `FutureWarning` | [get_or_set 的鎖](#get-or-set-lock) | | `get_session_manager` 透過 `SessionManagerProxy` 取得 | [#131](https://github.com/allen0099/FastAPI-CacheX/issues/131) | `FutureWarning` | [get_session_manager](#get-session-manager) | @@ -83,7 +83,7 @@ config = SessionConfig( ) ``` -0.4.0 也會拒絕互相矛盾的設定:`__Host-` 名稱搭配 `cookie_https_only=False`、`"/"` 以外的 `cookie_path` 或 `cookie_domain`,以及 `__Secure-` 名稱搭配 `cookie_https_only=False`。瀏覽器本來就會拒絕這樣的 Cookie,所以 Session 永遠無法保存;0.3.9 在以這些設定建立 `SessionConfig` 時會發出 `UserWarning`。 +0.4.0 也會拒絕互相矛盾的設定:`__Host-` 名稱搭配 `cookie_https_only=False`、`"/"` 以外的 `cookie_path` 或 `cookie_domain`,以及 `__Secure-` 名稱搭配 `cookie_https_only=False`。瀏覽器本來就會拒絕這樣的 Cookie,所以 Session 永遠無法保存;0.3.9 在以這些設定建立 `SessionConfig` 時會發出 `UserWarning`。拒絕時 `SessionConfig` 會引發 `pydantic.ValidationError`(屬於 `ValueError`)。由於預設名稱改為 `__Host-session`,只設定 `cookie_https_only=False`、`cookie_path` 或 `cookie_domain`,而讓 `cookie_name` 維持預設值的設定也會引發錯誤。0.3.9 只在使用 `FastAPICacheXSessionMiddleware` 時透過上述 `FutureWarning` 警告這種情況;只使用標頭且設定了其中一個選項的設定不會收到警告。請一併設定 `cookie_name`,如第一個修改後範例所示;若沒有任何地方讀取 Cookie,也可以直接移除該選項。 ### 登入與登出 {#login-logout} diff --git a/i18n/zh-TW/docs/SESSION.md b/i18n/zh-TW/docs/SESSION.md index f3cf4cf..b1ecc70 100644 --- a/i18n/zh-TW/docs/SESSION.md +++ b/i18n/zh-TW/docs/SESSION.md @@ -10,7 +10,7 @@ FastAPI-CacheX 的 Session 管理提供完整的使用者 Session 處理,包 只支援標頭的 `SessionMiddleware` 自 0.3.1 起已棄用,**已於 0.4.0 移除**;見[遷移](#migration-sessionmiddleware-fastapicachexsessionmiddleware)。 -`SessionConfig` 的六個 `cookie_*` 設定(`cookie_name`、`cookie_max_age`、`cookie_path`、`cookie_same_site`、`cookie_https_only`、`cookie_domain`)**只有 `FastAPICacheXSessionMiddleware` 會讀取**;不透過它而直接使用 `SessionManager` 時,設定它們不會有任何效果。 +`SessionConfig` 的六個 `cookie_*` 設定(`cookie_name`、`cookie_max_age`、`cookie_path`、`cookie_same_site`、`cookie_https_only`、`cookie_domain`)**只有 `FastAPICacheXSessionMiddleware` 會讀取**;不透過它而直接使用 `SessionManager` 時,設定它們不會有任何效果,但 `SessionConfig` 仍會驗證它們(見 [Cookie 預設值](#cookie-defaults))。 完整可執行範例(英文):[`examples/session_login.py`](https://github.com/allen0099/FastAPI-CacheX/blob/master/examples/session_login.py)、[`examples/session_jwt.py`](https://github.com/allen0099/FastAPI-CacheX/blob/master/examples/session_jwt.py)。 @@ -156,28 +156,25 @@ SessionConfig( # 後端 backend_key_prefix="session:", # Cookie(只有 FastAPICacheXSessionMiddleware 會讀取) - cookie_name="session", # 0.4.0 起為 "__Host-session" + cookie_name="__Host-session", # 見下方「Cookie 預設值」 cookie_max_age=14 * 24 * 60 * 60, # None = 不設 Max-Age(Cookie 隨瀏覽器工作階段結束) cookie_path="/", cookie_same_site="lax", # "lax" / "strict" / "none"("none" 需要 cookie_https_only=True) - cookie_https_only=False, # True 會加上 Secure 旗標;0.4.0 起為 True + cookie_https_only=True, # Secure 旗標:Cookie 只透過 HTTPS 傳送 cookie_domain=None, # None = 不設 Domain 屬性 ) ``` -#### 0.4.0 的 Cookie 預設值變更 {#cookie-defaults-change-in-040} +#### Cookie 預設值 {#cookie-defaults} -0.4.0 會將 Session Cookie 命名為 `__Host-session`,並預設加上 `Secure` 旗標。瀏覽器只接受帶 `Secure`、`Path=/` 且沒有 `Domain` 的 `__Host-` Cookie,也不接受子網域設定的這種 Cookie,因此移除了植入 Session Cookie 最常見的途徑(Session 固定攻擊(session fixation))。新名稱也代表升級後,所有持有 `session` Cookie 的瀏覽器都會被登出一次。 +Session Cookie 預設命名為 `__Host-session`,並帶有 `Secure` 旗標。瀏覽器只接受帶 `Secure`、`Path=/` 且沒有 `Domain` 的 `__Host-` Cookie,也不接受子網域設定的這種 Cookie,因此移除了植入 Session Cookie 最常見的途徑(Session 固定攻擊(session fixation))。 -在那之前,`FastAPICacheXSessionMiddleware` 在建構時(應用程式建立中介軟體堆疊時,也就是啟動時或第一個請求時),若設定讓 `cookie_name` 或 `cookie_https_only` 維持預設值,會發出 `FutureWarning`。明確設定兩者即可消除警告: +`Secure` Cookie 不會透過純 HTTP 傳送。沒有 TLS 的本機開發環境,請使用不帶前綴的名稱並關閉此旗標:`cookie_name="session", cookie_https_only=False`。 -- `cookie_name="session", cookie_https_only=False` 保留目前的 Cookie(升級後也能繼續運作,例如透過純 HTTP 進行本機開發); -- `cookie_name="__Host-session", cookie_https_only=True` 現在就切換,需透過 HTTPS。 - -`__Host-` 名稱搭配 `cookie_https_only=False`、`/` 以外的 `cookie_path` 或 `cookie_domain`(以及 `__Secure-` 名稱未搭配 `cookie_https_only=True`)會發出 `UserWarning`,因為瀏覽器會拒絕這樣的 Cookie;0.4.0 會拒絕這種組合。請參閱[遷移至 0.4.0](MIGRATING_0_4.md#session-cookie)。 +對瀏覽器會拒絕的 Cookie,`SessionConfig` 會引發 `ValidationError`:`__Host-` 名稱搭配 `cookie_https_only=False`、`/` 以外的 `cookie_path` 或 `cookie_domain`,以及 `__Secure-` 名稱未搭配 `cookie_https_only=True`。預設名稱帶有 `__Host-` 前綴,因此只變更其中一項設定也會引發錯誤;請一併變更 `cookie_name`。從 0.3.x 升級會變更 Cookie 名稱,使所有 Cookie Session 被登出一次;請參閱[遷移至 0.4.0](MIGRATING_0_4.md#session-cookie)。 Session 會在 `session_ttl` 秒後過期。啟用 `sliding_expiration` 時,每個發現剩餘時間少於 `session_ttl * sliding_threshold` 秒的請求,都會將過期時間重新延長為完整的 `session_ttl`,並發行一個更新後的權杖,由中介軟體傳回給用戶端(回應標頭或 `Set-Cookie`,見上表)。標頭/Bearer 用戶端在回應帶有 `header_name` 標頭時,應以它取代已保存的權杖。`absolute_timeout` 會在 Session 建立後經過該秒數時結束 Session,不論是否有滑動更新:過期時間、後端 TTL 與 JWT 的 `exp` 都不會超過 `created_at + absolute_timeout`,過期時間到達這個上限後也不再發行更新後的權杖。 @@ -243,13 +240,13 @@ config = SessionConfig(secret_key=secret_key) ### 2. 僅限 HTTPS {#2-https-only} -正式環境中一律透過 HTTPS 傳輸權杖。對 Cookie 用戶端,請將 Cookie 標記為 `Secure`: +正式環境中一律透過 HTTPS 傳輸權杖。對 Cookie 用戶端,請保留 Cookie 的 `Secure` 旗標(預設即是如此): ```python config = SessionConfig( secret_key="...", - cookie_name="__Host-session", # 瀏覽器只接受帶 Secure、Path=/ 且沒有 Domain 的這種 Cookie - cookie_https_only=True, # 為 Session Cookie 加上 Secure 旗標 + cookie_name="__Host-session", # 預設值;瀏覽器只接受帶 Secure、Path=/ 且沒有 Domain 的這種 Cookie + cookie_https_only=True, # 預設值;為 Session Cookie 加上 Secure 旗標 ) ``` diff --git a/tests/session/test_0_4_0_notices.py b/tests/session/test_0_4_0_notices.py index 029e5a6..2150026 100644 --- a/tests/session/test_0_4_0_notices.py +++ b/tests/session/test_0_4_0_notices.py @@ -1,6 +1,5 @@ """Advance notices for the session changes 0.4.0 makes (#352). -- #256: the default session cookie becomes ``__Host-session`` with Secure. - #131: ``get_session_manager`` resolves through ``SessionManagerProxy`` only. - #75: a ``token_source_priority`` without ``"cookie"`` disables the cookie. - #377: ``SessionConfig.use_bearer_token`` is removed. @@ -27,130 +26,12 @@ SECRET = "a" * 32 -# --- #256: default cookie name and Secure flag --------------------------------- - def _middleware(config: SessionConfig) -> FastAPICacheXSessionMiddleware: manager = SessionManager(MemoryBackend(), config) return FastAPICacheXSessionMiddleware(FastAPI(), session_manager=manager) -@pytest.mark.parametrize( - ("settings", "named"), - [ - ({}, "default cookie_name and cookie_https_only"), - ({"cookie_https_only": True}, "default cookie_name of"), - ({"cookie_name": "session"}, "default cookie_https_only of"), - ], -) -def test_middleware_warns_while_a_cookie_default_is_relied_upon( - settings: dict[str, object], named: str -) -> None: - config = SessionConfig(secret_key=SECRET, **settings) - - with pytest.warns(FutureWarning, match=named) as record: - _middleware(config) - - message = str(record[0].message) - assert "__Host-session" in message - assert "cookie_name='session', cookie_https_only=False" in message - - -@pytest.mark.parametrize( - "settings", - [ - {"cookie_name": "session", "cookie_https_only": False}, - {"cookie_name": "__Host-session", "cookie_https_only": True}, - {"cookie_name": "sid", "cookie_https_only": True}, - ], -) -def test_middleware_is_silent_with_explicit_cookie_settings( - settings: dict[str, object], -) -> None: - config = SessionConfig(secret_key=SECRET, **settings) - - with warnings.catch_warnings(): - warnings.simplefilter("error") - _middleware(config) - - -def test_middleware_checks_the_config_it_falls_back_to() -> None: - """With no ``config=``, the manager's config is the one that counts.""" - manager = SessionManager(MemoryBackend(), SessionConfig(secret_key=SECRET)) - - with pytest.warns(FutureWarning, match="default cookie_name and"): - FastAPICacheXSessionMiddleware(FastAPI(), session_manager=manager) - - -def test_middleware_warns_when_the_app_builds_its_stack() -> None: - """``add_middleware`` defers construction; the first request builds it.""" - manager = SessionManager(MemoryBackend(), SessionConfig(secret_key=SECRET)) - app = FastAPI() - app.add_middleware(FastAPICacheXSessionMiddleware, session_manager=manager) - - @app.get("/") - async def index() -> dict[str, bool]: - return {"ok": True} - - with pytest.warns(FutureWarning, match="__Host-session"): - response = TestClient(app).get("/") - - assert response.status_code == 200 - - -@pytest.mark.parametrize( - ("settings", "requirement"), - [ - ({"cookie_name": "__Host-session"}, "cookie_https_only=True"), - ({"cookie_name": "__Secure-session"}, "cookie_https_only=True"), - ( - { - "cookie_name": "__Host-session", - "cookie_https_only": True, - "cookie_path": "/app", - }, - 'cookie_path="/"', - ), - ( - { - "cookie_name": "__Host-session", - "cookie_https_only": True, - "cookie_domain": "example.com", - }, - "cookie_domain=None", - ), - ], -) -def test_prefixed_cookie_names_browsers_refuse_warn( - settings: dict[str, object], requirement: str -) -> None: - with pytest.warns(UserWarning, match="Version 0.4.0 will reject") as record: - SessionConfig(secret_key=SECRET, **settings) - - message = str(record[0].message) - assert requirement in message - assert "issues/256" in message - - -@pytest.mark.parametrize( - "settings", - [ - {"cookie_name": "__Host-session", "cookie_https_only": True}, - { - "cookie_name": "__Secure-session", - "cookie_https_only": True, - "cookie_path": "/app", - "cookie_domain": "example.com", - }, - {"cookie_name": "session", "cookie_domain": "example.com"}, - ], -) -def test_valid_cookie_prefixes_do_not_warn(settings: dict[str, object]) -> None: - with warnings.catch_warnings(): - warnings.simplefilter("error") - SessionConfig(secret_key=SECRET, **settings) - - # --- #131: get_session_manager through SessionManagerProxy --------------------- diff --git a/tests/session/test_config.py b/tests/session/test_config.py index c2b0f65..af10182 100644 --- a/tests/session/test_config.py +++ b/tests/session/test_config.py @@ -31,7 +31,12 @@ def test_session_config_rejects_unknown_fields() -> None: def test_same_site_none_without_https_only_warns() -> None: """Browsers drop a SameSite=None cookie that is not Secure (#167).""" with pytest.warns(UserWarning, match='cookie_same_site="none"'): - SessionConfig(secret_key="a" * 32, cookie_same_site="none") + SessionConfig( + secret_key="a" * 32, + cookie_name="session", + cookie_same_site="none", + cookie_https_only=False, + ) @pytest.mark.parametrize( @@ -43,6 +48,97 @@ def test_other_cookie_settings_do_not_warn(same_site, https_only) -> None: warnings.simplefilter("error") SessionConfig( secret_key="a" * 32, + cookie_name="session", cookie_same_site=same_site, cookie_https_only=https_only, ) + + +# --- Cookie prefixes (#256) ----------------------------------------------------- + + +_PLAIN_HTTP_HINT = "cookie_name='session' with cookie_https_only=False" +_SECURE_PREFIX_HINT = "use cookie_name='__Secure-session', which keeps the Secure flag" + + +@pytest.mark.parametrize( + ("settings", "requirements", "hints"), + [ + ( + {"cookie_https_only": False}, + ["cookie_https_only=True"], + [_PLAIN_HTTP_HINT], + ), + ( + {"cookie_name": "__Secure-session", "cookie_https_only": False}, + ["cookie_https_only=True"], + [_PLAIN_HTTP_HINT], + ), + ({"cookie_path": "/app"}, ['cookie_path="/"'], [_SECURE_PREFIX_HINT]), + ( + {"cookie_domain": "example.com"}, + ["cookie_domain=None"], + [_SECURE_PREFIX_HINT], + ), + ( + {"cookie_https_only": False, "cookie_path": "/app"}, + ["cookie_https_only=True", 'cookie_path="/"'], + [_PLAIN_HTTP_HINT, _SECURE_PREFIX_HINT], + ), + ], +) +def test_prefixed_cookie_names_browsers_refuse_are_rejected( + settings: dict[str, object], requirements: list[str], hints: list[str] +) -> None: + """A cookie browsers would refuse never sticks, so the config is an error. + + The default name is ``__Host-session``, so changing only the Secure flag, + path or domain is enough to hit this, and the message says the name is + the default. The hint fits the problem: dropping Secure is advice for + plain HTTP only, not for someone setting a path or domain. + """ + with pytest.raises(ValidationError, match="cookie_name=") as excinfo: + SessionConfig(secret_key="a" * 32, **settings) + + message = str(excinfo.value) + for requirement in requirements: + assert requirement in message + for hint in (_PLAIN_HTTP_HINT, _SECURE_PREFIX_HINT): + assert (hint in message) is (hint in hints) + assert ("(the default)" in message) is ("cookie_name" not in settings) + assert "SESSION/#cookie-defaults" in message + + +def test_same_site_none_with_a_prefixed_name_is_only_rejected() -> None: + """The prefix check covers the missing Secure flag; no extra warning.""" + with warnings.catch_warnings(): + warnings.simplefilter("error") + with pytest.raises(ValidationError, match="cookie_https_only=True"): + SessionConfig( + secret_key="a" * 32, + cookie_same_site="none", + cookie_https_only=False, + ) + + +@pytest.mark.parametrize( + "settings", + [ + {}, + { + "cookie_name": "__Secure-session", + "cookie_path": "/app", + "cookie_domain": "example.com", + }, + { + "cookie_name": "session", + "cookie_https_only": False, + "cookie_path": "/app", + "cookie_domain": "example.com", + }, + ], +) +def test_valid_cookie_prefixes_are_accepted(settings: dict[str, object]) -> None: + with warnings.catch_warnings(): + warnings.simplefilter("error") + SessionConfig(secret_key="a" * 32, **settings) diff --git a/tests/session/test_starlette_middleware.py b/tests/session/test_starlette_middleware.py index 937ae5b..f3f41dc 100644 --- a/tests/session/test_starlette_middleware.py +++ b/tests/session/test_starlette_middleware.py @@ -67,17 +67,46 @@ async def app(scope, receive, send): def test_default_cookie_config_values() -> None: - """Ensure cookie defaults mirror Starlette's own SessionMiddleware defaults.""" + """The cookie defaults to a ``__Host-`` name with Secure (#256).""" config = SessionConfig(secret_key="a" * 32) - assert config.cookie_name == "session" + assert config.cookie_name == "__Host-session" assert config.cookie_max_age == 14 * 24 * 60 * 60 assert config.cookie_path == "/" assert config.cookie_same_site == "lax" - assert config.cookie_https_only is False + assert config.cookie_https_only is True assert config.cookie_domain is None +def test_default_cookie_is_host_prefixed_and_round_trips_over_https() -> None: + """The default config sends a cookie browsers accept only from this host. + + Neither the config nor the middleware warns about it any more (#256). + """ + config = SessionConfig(secret_key="a" * 32) + manager = SessionManager(MemoryBackend(), config) + app = FastAPI() + app.add_middleware(FastAPICacheXSessionMiddleware, session_manager=manager) + + @app.get("/set") + async def set_route(request: Request) -> dict[str, bool]: + request.session["foo"] = "bar" + return {"ok": True} + + @app.get("/get") + async def get_route(request: Request) -> dict[str, Any]: + return {"foo": request.session.get("foo")} + + client = TestClient(app, base_url="https://testserver") + set_cookie = client.get("/set").headers["set-cookie"] + + assert set_cookie.startswith("__Host-session=") + assert "; path=/;" in set_cookie + assert "secure" in set_cookie + assert "domain" not in set_cookie + assert client.get("/get").json() == {"foo": "bar"} + + def test_set_cookie_header_includes_secure_and_domain_flags( manager: SessionManager, ) -> None: