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):