Merge commit from fork

request context tracks session access
This commit is contained in:
David Lord 2026-02-18 19:35:58 -08:00 committed by GitHub
commit 089cb86dd2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 73 additions and 52 deletions

View file

@ -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
-------------

View file

@ -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

View file

@ -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.

View file

@ -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

View file

@ -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

View file

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