From 40ed97132b22a99bee3c8b7d819c2113a767aabe Mon Sep 17 00:00:00 2001 From: David Lord Date: Wed, 18 Feb 2026 19:02:54 -0800 Subject: [PATCH] request context tracks session access CVE: CVE-2026-27205 Upstream-Status: Backport [https://github.com/pallets/flask/commit/c17f379390731543eea33a570a47bd4ef76a54fa] Backport Changes: - Omitted CHANGES.rst because the upstream advisory entry targets the Flask 3.1.3 release section, which is absent from Scarthgap 3.0.3 and is not required for the runtime fix. - Retained the Flask 3.0.3 SecureCookieSession.__init__() t.Any annotation because upstream's collections.abc.Mapping typing layout is not present in this version. The session-access behavior and all regression tests are preserved unchanged. (cherry picked from commit c17f379390731543eea33a570a47bd4ef76a54fa) Signed-off-by: Darsh Kelaiya --- src/flask/app.py | 4 +-- src/flask/ctx.py | 22 +++++++++++----- src/flask/sessions.py | 32 +++++++---------------- src/flask/templating.py | 7 ++--- tests/test_basic.py | 57 ++++++++++++++++++++++++++++------------- 5 files changed, 70 insertions(+), 52 deletions(-) diff --git a/src/flask/app.py b/src/flask/app.py index 7622b5e8..d537ad80 100644 --- a/src/flask/app.py +++ b/src/flask/app.py @@ -1280,8 +1280,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 9b164d39..cf285bc0 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 ee19ad63..1e70ef7b 100644 --- a/src/flask/sessions.py +++ b/src/flask/sessions.py @@ -43,10 +43,15 @@ class SessionMixin(MutableMapping): # type: ignore[type-arg] #: ``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. + """ # TODO generic when Python > 3.8 @@ -66,31 +71,12 @@ class SecureCookieSession(CallbackDict, SessionMixin): # type: ignore[type-arg] #: 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: t.Any = 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 618a3b35..5f7480da 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 214cfee0..754fe589 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -18,6 +18,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", @@ -229,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 - - assert client.post("/set", data={"value": "42"}).data == b"value set" - assert client.get("/get").data == b"42" + @app.get("/") + def do_get(): + return flask.session.get("value", "None") + + @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): -- 2.44.4