Merge branch 'stable'

This commit is contained in:
David Lord 2026-02-18 21:56:24 -08:00
commit daca74d93a
No known key found for this signature in database
GPG key ID: 43368A7AA8CC5926
6 changed files with 64 additions and 47 deletions

View file

@ -27,6 +27,15 @@ Unreleased
it's disabled in config. Previously, only disabling worked. :issue:`5916` it's disabled in config. Previously, only disabling worked. :issue:`5916`
Version 3.1.3
-------------
Released 2026-02-18
- The session is marked as accessed for operations that only access the keys
but not the values, such as ``in`` and ``len``. :ghsa:`68rp-wp8r-4726`
Version 3.1.2 Version 3.1.2
------------- -------------

View file

@ -1411,8 +1411,8 @@ class Flask(App):
for func in reversed(self.after_request_funcs[name]): for func in reversed(self.after_request_funcs[name]):
response = self.ensure_sync(func)(response) response = self.ensure_sync(func)(response)
if not self.session_interface.is_null_session(ctx.session): if not self.session_interface.is_null_session(ctx._session):
self.session_interface.save_session(self, ctx.session, response) self.session_interface.save_session(self, ctx._session, response)
return response return response

View file

@ -381,7 +381,7 @@ class AppContext:
def session(self) -> SessionMixin: def session(self) -> SessionMixin:
"""The session object associated with this context. Accessed through """The session object associated with this context. Accessed through
:data:`.session`. Only available in request contexts, otherwise raises :data:`.session`. Only available in request contexts, otherwise raises
:exc:`RuntimeError`. :exc:`RuntimeError`. Accessing this sets :attr:`.SessionMixin.accessed`.
""" """
if self._request is None: if self._request is None:
raise RuntimeError("There is no request in this context.") raise RuntimeError("There is no request in this context.")
@ -393,6 +393,7 @@ class AppContext:
if self._session is None: if self._session is None:
self._session = si.make_null_session(self.app) self._session = si.make_null_session(self.app)
self._session.accessed = True
return self._session return self._session
def match_request(self) -> None: def match_request(self) -> None:

View file

@ -43,10 +43,15 @@ class SessionMixin(MutableMapping[str, t.Any]):
#: ``True``. #: ``True``.
modified = True modified = True
#: Some implementations can detect when session data is read or accessed = False
#: written and set this when that happens. The mixin default is hard """Indicates if the session was accessed, even if it was not modified. This
#: coded to ``True``. is set when the session object is accessed through the request context,
accessed = True including the global :data:`.session` proxy. A ``Vary: cookie`` header will
be added if this is ``True``.
.. versionchanged:: 3.1.3
This is tracked by the request context.
"""
class SecureCookieSession(CallbackDict[str, t.Any], SessionMixin): class SecureCookieSession(CallbackDict[str, t.Any], SessionMixin):
@ -65,34 +70,15 @@ class SecureCookieSession(CallbackDict[str, t.Any], SessionMixin):
#: will only be written to the response if this is ``True``. #: will only be written to the response if this is ``True``.
modified = False modified = False
#: When data is read or written, this is set to ``True``. Used by
# :class:`.SecureCookieSessionInterface` to add a ``Vary: Cookie``
#: header, which allows caching proxies to cache different pages for
#: different users.
accessed = False
def __init__( def __init__(
self, self,
initial: c.Mapping[str, t.Any] | c.Iterable[tuple[str, t.Any]] | None = None, initial: c.Mapping[str, t.Any] | None = None,
) -> None: ) -> None:
def on_update(self: te.Self) -> None: def on_update(self: te.Self) -> None:
self.modified = True self.modified = True
self.accessed = True
super().__init__(initial, on_update) super().__init__(initial, on_update)
def __getitem__(self, key: str) -> t.Any:
self.accessed = True
return super().__getitem__(key)
def get(self, key: str, default: t.Any = None) -> t.Any:
self.accessed = True
return super().get(key, default)
def setdefault(self, key: str, default: t.Any = None) -> t.Any:
self.accessed = True
return super().setdefault(key, default)
class NullSession(SecureCookieSession): class NullSession(SecureCookieSession):
"""Class used to generate nicer error messages if sessions are not """Class used to generate nicer error messages if sessions are not

View file

@ -19,15 +19,16 @@ if t.TYPE_CHECKING: # pragma: no cover
def _default_template_ctx_processor() -> dict[str, t.Any]: def _default_template_ctx_processor() -> dict[str, t.Any]:
"""Default template context processor. Injects `request`, """Default template context processor. Replaces the ``request`` and ``g``
`session` and `g`. proxies with their concrete objects for faster access.
""" """
ctx = app_ctx._get_current_object() ctx = app_ctx._get_current_object()
rv: dict[str, t.Any] = {"g": ctx.g} rv: dict[str, t.Any] = {"g": ctx.g}
if ctx.has_request: if ctx.has_request:
rv["request"] = ctx.request rv["request"] = ctx.request
rv["session"] = ctx.session # The session proxy cannot be replaced, accessing it gets
# RequestContext.session, which sets session.accessed.
return rv return rv

View file

@ -20,6 +20,7 @@ from werkzeug.routing import BuildError
from werkzeug.routing import RequestRedirect from werkzeug.routing import RequestRedirect
import flask import flask
from flask.globals import app_ctx
from flask.testing import FlaskClient from flask.testing import FlaskClient
require_cpython_gc = pytest.mark.skipif( require_cpython_gc = pytest.mark.skipif(
@ -230,27 +231,46 @@ def test_endpoint_decorator(app, client):
assert client.get("/foo/bar").data == b"bar" assert client.get("/foo/bar").data == b"bar"
def test_session(app, client): def test_session_accessed(app: flask.Flask, client: FlaskClient) -> None:
@app.route("/set", methods=["POST"]) @app.post("/")
def set(): def do_set():
assert not flask.session.accessed
assert not flask.session.modified
flask.session["value"] = flask.request.form["value"] flask.session["value"] = flask.request.form["value"]
assert flask.session.accessed
assert flask.session.modified
return "value set" return "value set"
@app.route("/get") @app.get("/")
def get(): def do_get():
assert not flask.session.accessed return flask.session.get("value", "None")
assert not flask.session.modified
v = flask.session.get("value", "None")
assert flask.session.accessed
assert not flask.session.modified
return v
assert client.post("/set", data={"value": "42"}).data == b"value set" @app.get("/nothing")
assert client.get("/get").data == b"42" def do_nothing() -> str:
return ""
with client:
rv = client.get("/nothing")
assert "cookie" not in rv.vary
assert not app_ctx._session.accessed
assert not app_ctx._session.modified
with client:
rv = client.post(data={"value": "42"})
assert rv.text == "value set"
assert "cookie" in rv.vary
assert app_ctx._session.accessed
assert app_ctx._session.modified
with client:
rv = client.get()
assert rv.text == "42"
assert "cookie" in rv.vary
assert app_ctx._session.accessed
assert not app_ctx._session.modified
with client:
rv = client.get("/nothing")
assert rv.text == ""
assert "cookie" not in rv.vary
assert not app_ctx._session.accessed
assert not app_ctx._session.modified
def test_session_path(app, client): def test_session_path(app, client):