diff --git a/CHANGES.rst b/CHANGES.rst index bd85117e..2464a90f 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -27,6 +27,15 @@ Unreleased 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 ------------- diff --git a/src/flask/app.py b/src/flask/app.py index c17da4b4..72dea7bb 100644 --- a/src/flask/app.py +++ b/src/flask/app.py @@ -1411,8 +1411,8 @@ class Flask(App): for func in reversed(self.after_request_funcs[name]): response = self.ensure_sync(func)(response) - if not self.session_interface.is_null_session(ctx.session): - self.session_interface.save_session(self, ctx.session, response) + if not self.session_interface.is_null_session(ctx._session): + self.session_interface.save_session(self, ctx._session, response) return response diff --git a/src/flask/ctx.py b/src/flask/ctx.py index ba72b175..ac1422c7 100644 --- a/src/flask/ctx.py +++ b/src/flask/ctx.py @@ -381,7 +381,7 @@ class AppContext: def session(self) -> SessionMixin: """The session object associated with this context. Accessed through :data:`.session`. Only available in request contexts, otherwise raises - :exc:`RuntimeError`. + :exc:`RuntimeError`. Accessing this sets :attr:`.SessionMixin.accessed`. """ if self._request is None: raise RuntimeError("There is no request in this context.") @@ -393,6 +393,7 @@ class AppContext: if self._session is None: self._session = si.make_null_session(self.app) + self._session.accessed = True return self._session def match_request(self) -> None: diff --git a/src/flask/sessions.py b/src/flask/sessions.py index 1841d882..ad357706 100644 --- a/src/flask/sessions.py +++ b/src/flask/sessions.py @@ -43,10 +43,15 @@ class SessionMixin(MutableMapping[str, t.Any]): #: ``True``. modified = True - #: Some implementations can detect when session data is read or - #: written and set this when that happens. The mixin default is hard - #: coded to ``True``. - accessed = True + accessed = False + """Indicates if the session was accessed, even if it was not modified. This + is set when the session object is accessed through the request context, + 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): @@ -65,34 +70,15 @@ class SecureCookieSession(CallbackDict[str, t.Any], SessionMixin): #: will only be written to the response if this is ``True``. 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__( self, - initial: c.Mapping[str, t.Any] | c.Iterable[tuple[str, t.Any]] | None = None, + initial: c.Mapping[str, t.Any] | None = None, ) -> None: def on_update(self: te.Self) -> None: self.modified = True - self.accessed = True 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 used to generate nicer error messages if sessions are not diff --git a/src/flask/templating.py b/src/flask/templating.py index 4bb86d59..005108cc 100644 --- a/src/flask/templating.py +++ b/src/flask/templating.py @@ -19,15 +19,16 @@ if t.TYPE_CHECKING: # pragma: no cover def _default_template_ctx_processor() -> dict[str, t.Any]: - """Default template context processor. Injects `request`, - `session` and `g`. + """Default template context processor. Replaces the ``request`` and ``g`` + proxies with their concrete objects for faster access. """ ctx = app_ctx._get_current_object() rv: dict[str, t.Any] = {"g": ctx.g} if ctx.has_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 diff --git a/tests/test_basic.py b/tests/test_basic.py index 48365a6b..12690ed2 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -20,6 +20,7 @@ from werkzeug.routing import BuildError from werkzeug.routing import RequestRedirect import flask +from flask.globals import app_ctx from flask.testing import FlaskClient require_cpython_gc = pytest.mark.skipif( @@ -230,27 +231,46 @@ def test_endpoint_decorator(app, client): assert client.get("/foo/bar").data == b"bar" -def test_session(app, client): - @app.route("/set", methods=["POST"]) - def set(): - assert not flask.session.accessed - assert not flask.session.modified +def test_session_accessed(app: flask.Flask, client: FlaskClient) -> None: + @app.post("/") + def do_set(): flask.session["value"] = flask.request.form["value"] - assert flask.session.accessed - assert flask.session.modified return "value set" - @app.route("/get") - def get(): - assert not flask.session.accessed - assert not flask.session.modified - v = flask.session.get("value", "None") - assert flask.session.accessed - assert not flask.session.modified - return v + @app.get("/") + def do_get(): + return flask.session.get("value", "None") - assert client.post("/set", data={"value": "42"}).data == b"value set" - assert client.get("/get").data == b"42" + @app.get("/nothing") + 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):