Flask session does not add `Vary: Cookie` header when accessed in some ways
Description
Flask is a web server gateway interface (WSGI) web application framework. In versions 3.1.2 and below, when the session object is accessed, Flask should set the Vary: Cookie header., resulting in a Use of Cache Containing Sensitive Information vulnerability. The logic instructs caches not to cache the response, as it may contain information specific to a logged in user. This is handled in most cases, but some forms of access such as the Python in operator were overlooked. The severity and risk depend on the application being hosted behind a caching proxy that doesn't ignore responses with cookies, not setting a Cache-Control header to mark pages as private or non-cacheable, and accessing the session in a way that only touches keys without reading values or mutating the session. The issue has been fixed in version 3.1.3.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Flask 3.1.2 and below fails to set Vary: Cookie header when session keys are accessed via 'in' operator, potentially leaking sensitive data through caching.
Vulnerability
Description
Flask is a WSGI web application framework that, when the session object is accessed, should set the Vary: Cookie header to instruct caches not to cache the response due to session-specific content. In versions 3.1.2 and below, the code that marks the session as accessed missed certain operations, such as the Python in and len operators, meaning the session is not considered accessed when only keys are checked without reading values or mutating the session [1][2]. This oversight prevents the Vary: Cookie header from being set, leading to a Use of Cache Containing Sensitive Information vulnerability.
Exploitation
Conditions
Exploitation requires the application to be hosted behind a caching proxy that does not ignore responses with cookies, and the application must not set a Cache-Control header marking pages as private or non-cacheable. An attacker would need to trigger a request that accesses the session only via key-check operations (e.g., if "user" in session), without reading values or modifying the session. Under these conditions, the response may be cached and served to other users [1].
Impact
If successful, an attacker could obtain cached responses containing sensitive information tied to another user's session, such as user-specific content or authentication state. The severity depends on the application's reliance on caching and the sensitivity of the data stored in the session [1].
Mitigation
The issue has been fixed in Flask version 3.1.3, which properly marks the session as accessed for operations like in and len, ensuring the Vary: Cookie header is set appropriately [2][3]. Users are advised to upgrade to Flask 3.1.3 or later. No workarounds are provided beyond updating or ensuring caching headers are explicitly set to private.
AI Insight generated on May 19, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
flaskPyPI | < 3.1.3 | 3.1.3 |
Affected products
2- pallets/flaskv5Range: < 3.1.3
Patches
16 files changed · +74 −53
CHANGES.rst+3 −0 modified@@ -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 -------------
src/flask/app.py+2 −2 modified@@ -1318,8 +1318,8 @@ def process_response(self, response: Response) -> Response: 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
src/flask/ctx.py+16 −6 modified@@ -324,7 +324,7 @@ def __init__( 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 @@ def copy(self) -> 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 @@ def match_request(self) -> None: 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 @@ def push(self) -> None: # 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.
src/flask/sessions.py+10 −24 modified@@ -43,10 +43,15 @@ def permanent(self, value: bool) -> None: #: ``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
src/flask/templating.py+4 −3 modified@@ -22,8 +22,8 @@ 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
tests/test_basic.py+39 −18 modified@@ -20,6 +20,8 @@ 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 index(): 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):
Vulnerability mechanics
Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
5- github.com/advisories/GHSA-68rp-wp8r-4726ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-27205ghsaADVISORY
- github.com/pallets/flask/commit/089cb86dd22bff589a4eafb7ab8e42dc357623b4ghsax_refsource_MISCWEB
- github.com/pallets/flask/releases/tag/3.1.3ghsax_refsource_MISCWEB
- github.com/pallets/flask/security/advisories/GHSA-68rp-wp8r-4726ghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.