Security context
High· 7.5GHSA-m2qf-hxjv-5gpq CVE-2023-30861CWE-539Published May 1, 2023

Flask vulnerable to possible disclosure of permanent session cookie due to missing Vary: Cookie header

Research this vulnerability

Research is free — Hunters explains how the bug works, the root-cause code pattern, how the fix addresses it, and how to test whether a target is affected, in chat. Investigate & write exploit is a paid run — the engine reads the advisory and fix commits, then builds and validates a working proof-of-concept exploit with reproduction steps.

Affected versions

2.3.0 → fixed in 2.3.20 → fixed in 2.2.5

Details

When all of the following conditions are met, a response containing data intended for one client may be cached and subsequently sent by a proxy to other clients. If the proxy also caches `Set-Cookie` headers, it may send one client's `session` cookie to other clients. The severity depends on the application's use of the session, and the proxy's behavior regarding cookies. The risk depends on _all_ these conditions being met. 1. The application must be hosted behind a caching proxy that does not strip cookies or ignore responses with cookies. 2. The application sets [`session.permanent = True`](https://flask.palletsprojects.com/en/2.3.x/api/#flask.session.permanent). 2. The application does not access or modify the session at any point during a request. 4. [`SESSION_REFRESH_EACH_REQUEST`](https://flask.palletsprojects.com/en/2.3.x/config/#SESSION_REFRESH_EACH_REQUEST) is enabled (the default). 5. The application does not set a `Cache-Control` header to indicate that a page is private or should not be cached. This happens because vulnerable versions of Flask only set the `Vary: Cookie` header when the session is accessed or modified, not when it is refreshed (re-sent to update the expiration) without being accessed or modified.

The fix

set `Vary: Cookie` header consistently for session

David Lord· May 1, 2023, 03:01 PM+2948705dd39c4
src/flask/sessions.py+6 4
@@ -329,6 +329,10 @@ def save_session(
samesite = self.get_cookie_samesite(app)
httponly = self.get_cookie_httponly(app)
+ # Add a "Vary: Cookie" header if the session was accessed at all.
+ if session.accessed:
+ response.vary.add("Cookie")
+
# If the session is modified to be empty, remove the cookie.
# If the session is empty, return without setting the cookie.
if not session:
@@ -341,13 +345,10 @@ def save_session(
samesite=samesite,
httponly=httponly,
)
+ response.vary.add("Cookie")
return
- # Add a "Vary: Cookie" header if the session was accessed at all.
- if session.accessed:
- response.vary.add("Cookie")
-
if not self.should_set_cookie(app, session):
return
@@ -363,3 +364,4 @@ def save_session(
secure=secure,
samesite=samesite,
)
+ response.vary.add("Cookie")
tests/test_basic.py+23 0
@@ -501,6 +501,11 @@ def getitem():
def setdefault():
return flask.session.setdefault("test", "default")
+ @app.route("/clear")
+ def clear():
+ flask.session.clear()
+ return ""
+
@app.route("/vary-cookie-header-set")
def vary_cookie_header_set():
response = flask.Response()
@@ -533,11 +538,29 @@ def expect(path, header_value="Cookie"):
expect("/get")
expect("/getitem")
expect("/setdefault")
+ expect("/clear")
expect("/vary-cookie-header-set")
expect("/vary-header-set", "Accept-Encoding, Accept-Language, Cookie")
expect("/no-vary-header", None)
+def test_session_refresh_vary(app, client):
+ @app.get("/login")
+ def login():
+ flask.session["user_id"] = 1
+ flask.session.permanent = True
+ return ""
+
+ @app.get("/ignored")
+ def ignored():
+ return ""
+
+ rv = client.get("/login")
+ assert rv.headers["Vary"] == "Cookie"
+ rv = client.get("/ignored")
+ assert rv.headers["Vary"] == "Cookie"
+
+
def test_flashes(app, req_ctx):
assert not flask.session.modified
flask.flash("Zap")

set `Vary: Cookie` header consistently for session

David Lord· May 1, 2023, 03:01 PM+3048646edca6f
CHANGES.rst+1 0
@@ -4,6 +4,7 @@ Version 2.2.5
Unreleased
- Update for compatibility with Werkzeug 2.3.3.
+- Set ``Vary: Cookie`` header when the session is accessed, modified, or refreshed.
Version 2.2.4
src/flask/sessions.py+6 4
@@ -383,6 +383,10 @@ def save_session(
samesite = self.get_cookie_samesite(app)
httponly = self.get_cookie_httponly(app)
+ # Add a "Vary: Cookie" header if the session was accessed at all.
+ if session.accessed:
+ response.vary.add("Cookie")
+
# If the session is modified to be empty, remove the cookie.
# If the session is empty, return without setting the cookie.
if not session:
@@ -395,13 +399,10 @@ def save_session(
samesite=samesite,
httponly=httponly,
)
+ response.vary.add("Cookie")
return
- # Add a "Vary: Cookie" header if the session was accessed at all.
- if session.accessed:
- response.vary.add("Cookie")
-
if not self.should_set_cookie(app, session):
return
@@ -417,3 +418,4 @@ def save_session(
secure=secure,
samesite=samesite,
)
+ response.vary.add("Cookie")
tests/test_basic.py+23 0
@@ -560,6 +560,11 @@ def getitem():
def setdefault():
return flask.session.setdefault("test", "default")
+ @app.route("/clear")
+ def clear():
+ flask.session.clear()
+ return ""
+
@app.route("/vary-cookie-header-set")
def vary_cookie_header_set():
response = flask.Response()
@@ -592,11 +597,29 @@ def expect(path, header_value="Cookie"):
expect("/get")
expect("/getitem")
expect("/setdefault")
+ expect("/clear")
expect("/vary-cookie-header-set")
expect("/vary-header-set", "Accept-Encoding, Accept-Language, Cookie")
expect("/no-vary-header", None)
+def test_session_refresh_vary(app, client):
+ @app.get("/login")
+ def login():
+ flask.session["user_id"] = 1
+ flask.session.permanent = True
+ return ""
+
+ @app.get("/ignored")
+ def ignored():
+ return ""
+
+ rv = client.get("/login")
+ assert rv.headers["Vary"] == "Cookie"
+ rv = client.get("/ignored")
+ assert rv.headers["Vary"] == "Cookie"
+
+
def test_flashes(app, req_ctx):
assert not flask.session.modified
flask.flash("Zap")

References