From 27be9338405382445a7cb01151e084559b98d602 Mon Sep 17 00:00:00 2001 From: David Lord Date: Wed, 18 Feb 2026 14:52:52 -0800 Subject: [PATCH 1/3] start version 3.1.3 --- CHANGES.rst | 6 ++++++ pyproject.toml | 2 +- uv.lock | 4 ++-- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 2d70b95e..cc0cf0af 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,3 +1,9 @@ +Version 3.1.3 +------------- + +Unreleased + + Version 3.1.2 ------------- diff --git a/pyproject.toml b/pyproject.toml index f9558a48..80c3f3e9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "Flask" -version = "3.1.2" +version = "3.1.3.dev" description = "A simple framework for building complex web applications." readme = "README.md" license = "BSD-3-Clause" diff --git a/uv.lock b/uv.lock index dc1d66a4..49348a87 100644 --- a/uv.lock +++ b/uv.lock @@ -495,7 +495,7 @@ wheels = [ [[package]] name = "flask" -version = "3.1.2" +version = "3.1.3.dev0" source = { editable = "." } dependencies = [ { name = "blinker" }, @@ -854,7 +854,7 @@ name = "importlib-metadata" version = "8.7.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "zipp" }, + { name = "zipp", marker = "python_full_version < '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } wheels = [ From c17f379390731543eea33a570a47bd4ef76a54fa Mon Sep 17 00:00:00 2001 From: David Lord Date: Wed, 18 Feb 2026 19:02:54 -0800 Subject: [PATCH 2/3] request context tracks session access --- CHANGES.rst | 3 +++ src/flask/app.py | 4 +-- src/flask/ctx.py | 22 ++++++++++++----- src/flask/sessions.py | 34 ++++++++----------------- src/flask/templating.py | 7 +++--- tests/test_basic.py | 55 ++++++++++++++++++++++++++++------------- 6 files changed, 73 insertions(+), 52 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index cc0cf0af..71d29e7e 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -3,6 +3,9 @@ Version 3.1.3 Unreleased +- 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 c6dd7833..cc326dbe 100644 --- a/src/flask/app.py +++ b/src/flask/app.py @@ -1318,8 +1318,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 222e818e..5f7b1f1d 100644 --- a/src/flask/ctx.py +++ b/src/flask/ctx.py @@ -324,7 +324,7 @@ class RequestContext: except HTTPException as e: self.request.routing_exception = e self.flashes: list[tuple[str, str]] | None = None - self.session: SessionMixin | None = session + self._session: SessionMixin | None = session # Functions that should be executed after the request on the response # object. These will be called before the regular "after_request" # functions. @@ -351,7 +351,7 @@ class RequestContext: self.app, environ=self.request.environ, request=self.request, - session=self.session, + session=self._session, ) def match_request(self) -> None: @@ -364,6 +364,16 @@ class RequestContext: except HTTPException as e: self.request.routing_exception = e + @property + def session(self) -> SessionMixin: + """The session data associated with this request. Not available until + this context has been pushed. Accessing this property, also accessed by + the :data:`~flask.session` proxy, sets :attr:`.SessionMixin.accessed`. + """ + assert self._session is not None, "The session has not yet been opened." + self._session.accessed = True + return self._session + def push(self) -> None: # Before we push the request context we have to ensure that there # is an application context. @@ -381,12 +391,12 @@ class RequestContext: # This allows a custom open_session method to use the request context. # Only open a new session if this is the first time the request was # pushed, otherwise stream_with_context loses the session. - if self.session is None: + if self._session is None: session_interface = self.app.session_interface - self.session = session_interface.open_session(self.app, self.request) + self._session = session_interface.open_session(self.app, self.request) - if self.session is None: - self.session = session_interface.make_null_session(self.app) + if self._session is None: + self._session = session_interface.make_null_session(self.app) # Match the request URL after loading the session, so that the # session is available in custom URL converters. 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 16d480f5..c5fb5b99 100644 --- a/src/flask/templating.py +++ b/src/flask/templating.py @@ -22,8 +22,8 @@ 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. """ appctx = _cv_app.get(None) reqctx = _cv_request.get(None) @@ -32,7 +32,8 @@ def _default_template_ctx_processor() -> dict[str, t.Any]: rv["g"] = appctx.g if reqctx is not None: rv["request"] = reqctx.request - rv["session"] = reqctx.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 c372a910..4b3374e2 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -20,6 +20,8 @@ from werkzeug.routing import BuildError from werkzeug.routing import RequestRedirect import flask +from flask.globals import request_ctx +from flask.testing import FlaskClient require_cpython_gc = pytest.mark.skipif( python_implementation() != "CPython", @@ -231,27 +233,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 request_ctx._session.accessed + assert not request_ctx._session.modified + + with client: + rv = client.post(data={"value": "42"}) + assert rv.text == "value set" + assert "cookie" in rv.vary + assert request_ctx._session.accessed + assert request_ctx._session.modified + + with client: + rv = client.get() + assert rv.text == "42" + assert "cookie" in rv.vary + assert request_ctx._session.accessed + assert not request_ctx._session.modified + + with client: + rv = client.get("/nothing") + assert rv.text == "" + assert "cookie" not in rv.vary + assert not request_ctx._session.accessed + assert not request_ctx._session.modified def test_session_path(app, client): From 22d924701a6ae2e4cd01e9a15bbaf3946094af65 Mon Sep 17 00:00:00 2001 From: David Lord Date: Wed, 18 Feb 2026 19:41:55 -0800 Subject: [PATCH 3/3] release version 3.1.3 --- CHANGES.rst | 2 +- pyproject.toml | 2 +- uv.lock | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 71d29e7e..8368f3a2 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,7 +1,7 @@ Version 3.1.3 ------------- -Unreleased +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` diff --git a/pyproject.toml b/pyproject.toml index 80c3f3e9..697d2077 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "Flask" -version = "3.1.3.dev" +version = "3.1.3" description = "A simple framework for building complex web applications." readme = "README.md" license = "BSD-3-Clause" diff --git a/uv.lock b/uv.lock index 49348a87..6d5e07e2 100644 --- a/uv.lock +++ b/uv.lock @@ -495,7 +495,7 @@ wheels = [ [[package]] name = "flask" -version = "3.1.3.dev0" +version = "3.1.3" source = { editable = "." } dependencies = [ { name = "blinker" },