From 671c67aac96ae2cdf9c554ba4e52c167862be6fe Mon Sep 17 00:00:00 2001 From: Mihir Singh Date: Wed, 16 Apr 2014 17:38:41 -0400 Subject: [PATCH 1/4] Append a 'Vary: Cookie' header to the response when the session has been accessed --- flask/sessions.py | 58 ++++++++++++++++++++++++++++++++--------------- 1 file changed, 40 insertions(+), 18 deletions(-) diff --git a/flask/sessions.py b/flask/sessions.py index 82ba350645..99e3980da6 100644 --- a/flask/sessions.py +++ b/flask/sessions.py @@ -51,6 +51,13 @@ def _set_permanent(self, value): #: The default mixin implementation just hardcodes `True` in. modified = True + #: the accessed variable indicates whether or not the session object has + #: been accessed in that request. This allows flask to append a `Vary: + #: Cookie` header to the response if the session is being accessed. This + #: allows caching proxy servers, like Varnish, to use both the URL and the + #: session cookie as keys when caching pages, preventing multiple users + #: from being served the same cache. + accessed = True class TaggedJSONSerializer(object): """A customized JSON serializer that supports a few extra types that @@ -112,9 +119,18 @@ class SecureCookieSession(CallbackDict, SessionMixin): def __init__(self, initial=None): def on_update(self): self.modified = True + self.accessed = True CallbackDict.__init__(self, initial, on_update) self.modified = False + self.accessed = False + def __getitem__(self, key): + self.accessed = True + return super(SecureCookieSession, self).__getitem__(key) + + def get(self, key, default=None): + self.accessed = True + return super(SecureCookieSession, self).get(key, default) class NullSession(SecureCookieSession): """Class used to generate nicer error messages if sessions are not @@ -334,24 +350,30 @@ def save_session(self, app, session, response): domain = self.get_cookie_domain(app) path = self.get_cookie_path(app) - # Delete case. If there is no session we bail early. - # If the session was modified to be empty we remove the - # whole cookie. - if not session: - if session.modified: - response.delete_cookie(app.session_cookie_name, - domain=domain, path=path) - return - - # Modification case. There are upsides and downsides to - # emitting a set-cookie header each request. The behavior - # is controlled by the :meth:`should_set_cookie` method - # which performs a quick check to figure out if the cookie - # should be set or not. This is controlled by the - # SESSION_REFRESH_EACH_REQUEST config flag as well as - # the permanent flag on the session itself. - if not self.should_set_cookie(app, session): - return + if session.accessed: + + response.headers.add('Vary', 'Cookie') + + else: + + # Delete case. If there is no session we bail early. + # If the session was modified to be empty we remove the + # whole cookie. + if not session: + if session.modified: + response.delete_cookie(app.session_cookie_name, + domain=domain, path=path) + return + + # Modification case. There are upsides and downsides to + # emitting a set-cookie header each request. The behavior + # is controlled by the :meth:`should_set_cookie` method + # which performs a quick check to figure out if the cookie + # should be set or not. This is controlled by the + # SESSION_REFRESH_EACH_REQUEST config flag as well as + # the permanent flag on the session itself. + if not self.should_set_cookie(app, session): + return httponly = self.get_cookie_httponly(app) secure = self.get_cookie_secure(app) From 5935ee495c3e51d26fa2b33de09d532b310cd263 Mon Sep 17 00:00:00 2001 From: Mihir Singh Date: Wed, 16 Apr 2014 17:39:11 -0400 Subject: [PATCH 2/4] Add tests for the `Vary: Cookie` header --- flask/testsuite/basic.py | 50 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/flask/testsuite/basic.py b/flask/testsuite/basic.py index bc0838ad4a..4ab0bbb309 100644 --- a/flask/testsuite/basic.py +++ b/flask/testsuite/basic.py @@ -396,6 +396,56 @@ def run_test(expect_header): app.config['SESSION_REFRESH_EACH_REQUEST'] = False run_test(expect_header=False) + def test_session_vary_cookie(self): + + app = flask.Flask(__name__) + app.secret_key = 'testkey' + + @app.route('/set-session') + def set_session(): + + flask.session['test'] = 'test' + return '' + + @app.route('/get-session') + def get_session(): + + s = flask.session.get('test') + return '' + + @app.route('/get-session-with-dictionary') + def get_session_with_dictionary(): + + s = flask.session['test'] + return '' + + @app.route('/no-vary-header') + def no_vary_header(): + + return '' + + c = app.test_client() + + rv = c.get('/set-session') + + self.assert_in('Vary', rv.headers) + self.assert_equal('Cookie', rv.headers['Vary']) + + rv = c.get('/get-session') + + self.assert_in('Vary', rv.headers) + self.assert_equal('Cookie', rv.headers['Vary']) + + rv = c.get('/get-session-with-dictionary') + + self.assert_in('Vary', rv.headers) + self.assert_equal('Cookie', rv.headers['Vary']) + + rv = c.get('/no-vary-header') + + self.assert_not_in('Vary', rv.headers) + + def test_flashes(self): app = flask.Flask(__name__) app.secret_key = 'testkey' From ae133aa1734775f03560bde3d98c7f7d1c1569f7 Mon Sep 17 00:00:00 2001 From: David Lord Date: Sat, 20 May 2017 12:11:37 -0700 Subject: [PATCH 3/4] reorder session cookie checks to deleted, accessed, modified --- flask/sessions.py | 75 +++++++++++++++++++++++------------------------ 1 file changed, 37 insertions(+), 38 deletions(-) diff --git a/flask/sessions.py b/flask/sessions.py index 2eb887fc75..182ace858c 100644 --- a/flask/sessions.py +++ b/flask/sessions.py @@ -125,7 +125,8 @@ def __init__(self, initial=None): def on_update(self): self.modified = True self.accessed = True - CallbackDict.__init__(self, initial, on_update) + + super(SecureCookieSession, self).__init__(initial, on_update) self.modified = False self.accessed = False @@ -306,22 +307,20 @@ def get_expiration_time(self, app, session): return datetime.utcnow() + app.permanent_session_lifetime def should_set_cookie(self, app, session): - """Indicates whether a cookie should be set now or not. This is - used by session backends to figure out if they should emit a - set-cookie header or not. The default behavior is controlled by - the ``SESSION_REFRESH_EACH_REQUEST`` config variable. If - it's set to ``False`` then a cookie is only set if the session is - modified, if set to ``True`` it's always set if the session is - permanent. - - This check is usually skipped if sessions get deleted. + """Used by session backends to determine if a ``Set-Cookie`` header + should be set for this session cookie for this response. If the session + has been modified, the cookie is set. If the session is permanent and + the ``SESSION_REFRESH_EACH_REQUEST`` config is true, the cookie is + always set. + + This check is usually skipped if the session was deleted. .. versionadded:: 0.11 """ - if session.modified: - return True - save_each = app.config['SESSION_REFRESH_EACH_REQUEST'] - return save_each and session.permanent + + return session.modified or ( + session.permanent and app.config['SESSION_REFRESH_EACH_REQUEST'] + ) def open_session(self, app, request): """This method has to be implemented and must either return ``None`` @@ -387,35 +386,35 @@ def save_session(self, app, session, response): domain = self.get_cookie_domain(app) path = self.get_cookie_path(app) - if session.accessed: + # If the session is modified to be empty, remove the cookie. + # If the session is empty, return without setting the cookie. + if not session: + if session.modified: + response.delete_cookie( + app.session_cookie_name, + domain=domain, + path=path + ) + + return + # Add a "Vary: Cookie" header if the session was accessed at all. + if session.accessed: response.headers.add('Vary', 'Cookie') - else: - - # Delete case. If there is no session we bail early. - # If the session was modified to be empty we remove the - # whole cookie. - if not session: - if session.modified: - response.delete_cookie(app.session_cookie_name, - domain=domain, path=path) - return - - # Modification case. There are upsides and downsides to - # emitting a set-cookie header each request. The behavior - # is controlled by the :meth:`should_set_cookie` method - # which performs a quick check to figure out if the cookie - # should be set or not. This is controlled by the - # SESSION_REFRESH_EACH_REQUEST config flag as well as - # the permanent flag on the session itself. - if not self.should_set_cookie(app, session): - return + if not self.should_set_cookie(app, session): + return httponly = self.get_cookie_httponly(app) secure = self.get_cookie_secure(app) expires = self.get_expiration_time(app, session) val = self.get_signing_serializer(app).dumps(dict(session)) - response.set_cookie(app.session_cookie_name, val, - expires=expires, httponly=httponly, - domain=domain, path=path, secure=secure) + response.set_cookie( + app.session_cookie_name, + val, + expires=expires, + httponly=httponly, + domain=domain, + path=path, + secure=secure + ) From 5d9dd0b379a63d5a90265f2469f86fbd81b05853 Mon Sep 17 00:00:00 2001 From: David Lord Date: Sat, 20 May 2017 13:00:17 -0700 Subject: [PATCH 4/4] set session accessed for setdefault --- flask/sessions.py | 5 +++++ tests/test_basic.py | 40 ++++++++++++++++++++++------------------ 2 files changed, 27 insertions(+), 18 deletions(-) diff --git a/flask/sessions.py b/flask/sessions.py index 182ace858c..2dbd8b3282 100644 --- a/flask/sessions.py +++ b/flask/sessions.py @@ -138,6 +138,11 @@ def get(self, key, default=None): self.accessed = True return super(SecureCookieSession, self).get(key, default) + def setdefault(self, key, default=None): + self.accessed = True + return super(SecureCookieSession, self).setdefault(key, default) + + class NullSession(SecureCookieSession): """Class used to generate nicer error messages if sessions are not available. Will still allow read-only access to the empty session diff --git a/tests/test_basic.py b/tests/test_basic.py index dd9fd17e36..54f4c8e614 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -533,20 +533,22 @@ def test_session_vary_cookie(): app = flask.Flask(__name__) app.secret_key = 'testkey' - @app.route('/set-session') + @app.route('/set') def set_session(): flask.session['test'] = 'test' return '' - @app.route('/get-session') - def get_session(): - s = flask.session.get('test') - return '' + @app.route('/get') + def get(): + return flask.session.get('test') - @app.route('/get-session-with-dictionary') - def get_session_with_dictionary(): - s = flask.session['test'] - return '' + @app.route('/getitem') + def getitem(): + return flask.session['test'] + + @app.route('/setdefault') + def setdefault(): + return flask.session.setdefault('test', 'default') @app.route('/no-vary-header') def no_vary_header(): @@ -554,17 +556,19 @@ def no_vary_header(): c = app.test_client() - rv = c.get('/set-session') - assert rv.headers['Vary'] == 'Cookie' - - rv = c.get('/get-session') - assert rv.headers['Vary'] == 'Cookie' + def expect(path, header=True): + rv = c.get(path) - rv = c.get('/get-session-with-dictionary') - assert rv.headers['Vary'] == 'Cookie' + if header: + assert rv.headers['Vary'] == 'Cookie' + else: + assert 'Vary' not in rv.headers - rv = c.get('/no-vary-header') - assert 'Vary' not in rv.headers + expect('/set') + expect('/get') + expect('/getitem') + expect('/setdefault') + expect('/no-vary-header', False) def test_flashes():