diff --git a/django/conf/global_settings.py b/django/conf/global_settings.py index 8efa602e1e..befade160f 100644 --- a/django/conf/global_settings.py +++ b/django/conf/global_settings.py @@ -461,6 +461,9 @@ SESSION_COOKIE_SECURE = False SESSION_COOKIE_PATH = '/' # Whether to use the non-RFC standard httpOnly flag (IE, FF3+, others) SESSION_COOKIE_HTTPONLY = True +# Whether to set the flag restricting cookie leaks on cross-site requests. +# This can be 'Lax', 'Strict', or None to disable the flag. +SESSION_COOKIE_SAMESITE = 'Lax' # Whether to save the session data on every request. SESSION_SAVE_EVERY_REQUEST = False # Whether a user's session cookie expires when the Web browser is closed. @@ -537,6 +540,7 @@ CSRF_COOKIE_DOMAIN = None CSRF_COOKIE_PATH = '/' CSRF_COOKIE_SECURE = False CSRF_COOKIE_HTTPONLY = False +CSRF_COOKIE_SAMESITE = 'Lax' CSRF_HEADER_NAME = 'HTTP_X_CSRFTOKEN' CSRF_TRUSTED_ORIGINS = [] CSRF_USE_SESSIONS = False diff --git a/django/contrib/messages/storage/cookie.py b/django/contrib/messages/storage/cookie.py index 6a6a301db7..9e0c93e436 100644 --- a/django/contrib/messages/storage/cookie.py +++ b/django/contrib/messages/storage/cookie.py @@ -86,6 +86,7 @@ class CookieStorage(BaseStorage): domain=settings.SESSION_COOKIE_DOMAIN, secure=settings.SESSION_COOKIE_SECURE or None, httponly=settings.SESSION_COOKIE_HTTPONLY or None, + samesite=settings.SESSION_COOKIE_SAMESITE, ) else: response.delete_cookie(self.cookie_name, domain=settings.SESSION_COOKIE_DOMAIN) diff --git a/django/contrib/sessions/middleware.py b/django/contrib/sessions/middleware.py index 7263b6ac2d..6795354cc5 100644 --- a/django/contrib/sessions/middleware.py +++ b/django/contrib/sessions/middleware.py @@ -69,5 +69,6 @@ class SessionMiddleware(MiddlewareMixin): path=settings.SESSION_COOKIE_PATH, secure=settings.SESSION_COOKIE_SECURE or None, httponly=settings.SESSION_COOKIE_HTTPONLY or None, + samesite=settings.SESSION_COOKIE_SAMESITE, ) return response diff --git a/django/http/cookie.py b/django/http/cookie.py index b94d2b0386..5c418d7e35 100644 --- a/django/http/cookie.py +++ b/django/http/cookie.py @@ -3,6 +3,9 @@ from http import cookies # For backwards compatibility in Django 2.1. SimpleCookie = cookies.SimpleCookie +# Add support for the SameSite attribute (obsolete when PY37 is unsupported). +cookies.Morsel._reserved.setdefault('samesite', 'SameSite') + def parse_cookie(cookie): """ diff --git a/django/http/response.py b/django/http/response.py index b21b73f247..96c0cae597 100644 --- a/django/http/response.py +++ b/django/http/response.py @@ -154,7 +154,7 @@ class HttpResponseBase: return self._headers.get(header.lower(), (None, alternate))[1] def set_cookie(self, key, value='', max_age=None, expires=None, path='/', - domain=None, secure=False, httponly=False): + domain=None, secure=False, httponly=False, samesite=None): """ Set a cookie. @@ -194,6 +194,10 @@ class HttpResponseBase: self.cookies[key]['secure'] = True if httponly: self.cookies[key]['httponly'] = True + if samesite: + if samesite.lower() not in ('lax', 'strict'): + raise ValueError('samesite must be "lax" or "strict".') + self.cookies[key]['samesite'] = samesite def setdefault(self, key, value): """Set a header unless it has already been set.""" diff --git a/django/middleware/csrf.py b/django/middleware/csrf.py index a3a6eaf62f..10f878834d 100644 --- a/django/middleware/csrf.py +++ b/django/middleware/csrf.py @@ -190,6 +190,7 @@ class CsrfViewMiddleware(MiddlewareMixin): path=settings.CSRF_COOKIE_PATH, secure=settings.CSRF_COOKIE_SECURE, httponly=settings.CSRF_COOKIE_HTTPONLY, + samesite=settings.CSRF_COOKIE_SAMESITE, ) # Set the Vary header since content varies with the CSRF cookie. patch_vary_headers(response, ('Cookie',)) diff --git a/docs/ref/csrf.txt b/docs/ref/csrf.txt index 34660f5098..fdb373b002 100644 --- a/docs/ref/csrf.txt +++ b/docs/ref/csrf.txt @@ -513,6 +513,7 @@ A number of settings can be used to control Django's CSRF behavior: * :setting:`CSRF_COOKIE_HTTPONLY` * :setting:`CSRF_COOKIE_NAME` * :setting:`CSRF_COOKIE_PATH` +* :setting:`CSRF_COOKIE_SAMESITE` * :setting:`CSRF_COOKIE_SECURE` * :setting:`CSRF_FAILURE_VIEW` * :setting:`CSRF_HEADER_NAME` diff --git a/docs/ref/request-response.txt b/docs/ref/request-response.txt index c088186001..0caf37bc99 100644 --- a/docs/ref/request-response.txt +++ b/docs/ref/request-response.txt @@ -748,7 +748,7 @@ Methods Sets a header unless it has already been set. -.. method:: HttpResponse.set_cookie(key, value='', max_age=None, expires=None, path='/', domain=None, secure=None, httponly=False) +.. method:: HttpResponse.set_cookie(key, value='', max_age=None, expires=None, path='/', domain=None, secure=None, httponly=False, samesite=None) Sets a cookie. The parameters are the same as in the :class:`~http.cookies.Morsel` cookie object in the Python standard library. @@ -773,8 +773,17 @@ Methods when it is honored, it can be a useful way to mitigate the risk of a client-side script from accessing the protected cookie data. + * Use ``samesite='Strict'`` or ``samesite='Lax'`` to tell the browser not + to send this cookie when performing a cross-origin request. `SameSite`_ + isn't supported by all browsers, so it's not a replacement for Django's + CSRF protection, but rather a defense in depth measure. + + .. versionchanged:: 2.1 + + The ``samesite`` argument was added. .. _HTTPOnly: https://www.owasp.org/index.php/HTTPOnly + .. _SameSite: https://www.owasp.org/index.php/SameSite .. warning:: @@ -784,7 +793,7 @@ Methods to store a cookie of more than 4096 bytes, but many browsers will not set the cookie correctly. -.. method:: HttpResponse.set_signed_cookie(key, value, salt='', max_age=None, expires=None, path='/', domain=None, secure=None, httponly=True) +.. method:: HttpResponse.set_signed_cookie(key, value, salt='', max_age=None, expires=None, path='/', domain=None, secure=None, httponly=True, samesite=None) Like :meth:`~HttpResponse.set_cookie()`, but :doc:`cryptographic signing ` the cookie before setting diff --git a/docs/ref/settings.txt b/docs/ref/settings.txt index bc36f7c1d1..3647e60663 100644 --- a/docs/ref/settings.txt +++ b/docs/ref/settings.txt @@ -365,6 +365,20 @@ This is useful if you have multiple Django instances running under the same hostname. They can use different cookie paths, and each instance will only see its own CSRF cookie. +.. setting:: CSRF_COOKIE_SAMESITE + +``CSRF_COOKIE_SAMESITE`` +------------------------ + +.. versionadded:: 2.1 + +Default: ``'Lax'`` + +The value of the `SameSite`_ flag on the CSRF cookie. This flag prevents the +cookie from being sent in cross-site requests. + +See :setting:`SESSION_COOKIE_SAMESITE` for details about ``SameSite``. + .. setting:: CSRF_COOKIE_SECURE ``CSRF_COOKIE_SECURE`` @@ -3025,6 +3039,44 @@ This is useful if you have multiple Django instances running under the same hostname. They can use different cookie paths, and each instance will only see its own session cookie. +.. setting:: SESSION_COOKIE_SAMESITE + +``SESSION_COOKIE_SAMESITE`` +--------------------------- + +.. versionadded:: 2.1 + +Default: ``'Lax'`` + +The value of the `SameSite`_ flag on the session cookie. This flag prevents the +cookie from being sent in cross-site requests thus preventing CSRF attacks and +making some methods of stealing session cookie impossible. + +Possible values for the setting are: + +* ``'Strict'``: prevents the cookie from being sent by the browser to the + target site in all cross-site browsing context, even when following a regular + link. + + For example, for a GitHub-like website this would mean that if a logged-in + user follows a link to a private GitHub project posted on a corporate + discussion forum or email, GitHub will not receive the session cookie and the + user won't be able to access the project. A bank website, however, most + likely doesn't want to allow any transactional pages to be linked from + external sites so the ``'Strict'`` flag would be appropriate. + +* ``'Lax'`` (default): provides a balance between security and usability for + websites that want to maintain user's logged-in session after the user + arrives from an external link. + + In the GitHub scenario, the session cookie would be allowed when following a + regular link from an external website and be blocked in CSRF-prone request + methods (e.g. ``POST``). + +* ``None``: disables the flag. + +.. _SameSite: https://www.owasp.org/index.php/SameSite + .. setting:: SESSION_COOKIE_SECURE ``SESSION_COOKIE_SECURE`` @@ -3425,6 +3477,7 @@ Security * :setting:`CSRF_COOKIE_DOMAIN` * :setting:`CSRF_COOKIE_NAME` * :setting:`CSRF_COOKIE_PATH` + * :setting:`CSRF_COOKIE_SAMESITE` * :setting:`CSRF_COOKIE_SECURE` * :setting:`CSRF_FAILURE_VIEW` * :setting:`CSRF_HEADER_NAME` diff --git a/docs/releases/2.1.txt b/docs/releases/2.1.txt index d7737e486a..9044c3cd70 100644 --- a/docs/releases/2.1.txt +++ b/docs/releases/2.1.txt @@ -112,7 +112,8 @@ Minor features :mod:`django.contrib.sessions` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -* ... +* Added the :setting:`SESSION_COOKIE_SAMESITE` setting to set the ``SameSite`` + cookie flag on session cookies. :mod:`django.contrib.sitemaps` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -143,7 +144,8 @@ Cache CSRF ~~~~ -* ... +* Added the :setting:`CSRF_COOKIE_SAMESITE` setting to set the ``SameSite`` + cookie flag on CSRF cookies. Database backends ~~~~~~~~~~~~~~~~~ @@ -239,6 +241,9 @@ Requests and Responses * Added :meth:`.HttpRequest.get_full_path_info`. +* Added the ``samesite`` argument to :meth:`.HttpResponse.set_cookie` to allow + setting the ``SameSite`` cookie flag. + Serialization ~~~~~~~~~~~~~ @@ -338,6 +343,16 @@ variable now appears as an attribute of each option. For example, in a custom ``input_option.html`` template, change ``{% if wrap_label %}`` to ``{% if widget.wrap_label %}``. +``SameSite`` cookies +-------------------- + +The cookies used for ``django.contrib.sessions``, ``django.contrib.messages``, +and Django's CSRF protection now set the ``SameSite`` flag to ``Lax`` by +default. Browsers that respect this flag won't send these cookies on +cross-origin requests. If you rely on the old behavior, set the +:setting:`SESSION_COOKIE_SAMESITE` and/or :setting:`CSRF_COOKIE_SAMESITE` +setting to ``None``. + Miscellaneous ------------- diff --git a/docs/topics/http/sessions.txt b/docs/topics/http/sessions.txt index ce5d8019bd..745c735e46 100644 --- a/docs/topics/http/sessions.txt +++ b/docs/topics/http/sessions.txt @@ -629,6 +629,7 @@ behavior: * :setting:`SESSION_COOKIE_HTTPONLY` * :setting:`SESSION_COOKIE_NAME` * :setting:`SESSION_COOKIE_PATH` +* :setting:`SESSION_COOKIE_SAMESITE` * :setting:`SESSION_COOKIE_SECURE` * :setting:`SESSION_ENGINE` * :setting:`SESSION_EXPIRE_AT_BROWSER_CLOSE` diff --git a/tests/csrf_tests/tests.py b/tests/csrf_tests/tests.py index 8a9c509f4c..e63fbb8bd6 100644 --- a/tests/csrf_tests/tests.py +++ b/tests/csrf_tests/tests.py @@ -586,6 +586,14 @@ class CsrfViewMiddlewareTests(CsrfViewMiddlewareTestMixin, SimpleTestCase): max_age = resp2.cookies.get('csrfcookie').get('max-age') self.assertEqual(max_age, '') + def test_csrf_cookie_samesite(self): + req = self._get_GET_no_csrf_cookie_request() + with self.settings(CSRF_COOKIE_NAME='csrfcookie', CSRF_COOKIE_SAMESITE='Strict'): + self.mw.process_view(req, token_view, (), {}) + resp = token_view(req) + resp2 = self.mw.process_response(req, resp) + self.assertEqual(resp2.cookies['csrfcookie']['samesite'], 'Strict') + def test_process_view_token_too_long(self): """ If the token is longer than expected, it is ignored and a new token is diff --git a/tests/httpwrappers/tests.py b/tests/httpwrappers/tests.py index a387ca1f74..985380cc57 100644 --- a/tests/httpwrappers/tests.py +++ b/tests/httpwrappers/tests.py @@ -746,6 +746,11 @@ class CookieTests(unittest.TestCase): # document.cookie parses whitespace. self.assertEqual(parse_cookie(' = b ; ; = ; c = ; '), {'': 'b', 'c': ''}) + def test_samesite(self): + c = SimpleCookie('name=value; samesite=lax; httponly') + self.assertEqual(c['name']['samesite'], 'lax') + self.assertIn('SameSite=lax', c.output()) + def test_httponly_after_load(self): c = SimpleCookie() c.load("name=val") diff --git a/tests/messages_tests/test_cookie.py b/tests/messages_tests/test_cookie.py index a5eff30fd4..211d33f04c 100644 --- a/tests/messages_tests/test_cookie.py +++ b/tests/messages_tests/test_cookie.py @@ -57,6 +57,7 @@ class CookieTests(BaseTests, SimpleTestCase): # The message contains what's expected. self.assertEqual(list(storage), example_messages) + @override_settings(SESSION_COOKIE_SAMESITE='Strict') def test_cookie_setings(self): """ CookieStorage honors SESSION_COOKIE_DOMAIN, SESSION_COOKIE_SECURE, and @@ -72,6 +73,7 @@ class CookieTests(BaseTests, SimpleTestCase): self.assertEqual(response.cookies['messages']['expires'], '') self.assertIs(response.cookies['messages']['secure'], True) self.assertIs(response.cookies['messages']['httponly'], True) + self.assertEqual(response.cookies['messages']['samesite'], 'Strict') # Test deletion of the cookie (storing with an empty value) after the messages have been consumed storage = self.get_storage() diff --git a/tests/responses/test_cookie.py b/tests/responses/test_cookie.py index 148963fa59..a5092c3bbf 100644 --- a/tests/responses/test_cookie.py +++ b/tests/responses/test_cookie.py @@ -79,6 +79,17 @@ class SetCookieTests(SimpleTestCase): response.set_cookie('test', cookie_value) self.assertEqual(response.cookies['test'].value, cookie_value) + def test_samesite(self): + response = HttpResponse() + response.set_cookie('example', samesite='Lax') + self.assertEqual(response.cookies['example']['samesite'], 'Lax') + response.set_cookie('example', samesite='strict') + self.assertEqual(response.cookies['example']['samesite'], 'strict') + + def test_invalid_samesite(self): + with self.assertRaisesMessage(ValueError, 'samesite must be "lax" or "strict".'): + HttpResponse().set_cookie('example', samesite='invalid') + class DeleteCookieTests(SimpleTestCase): diff --git a/tests/sessions_tests/tests.py b/tests/sessions_tests/tests.py index 8f3f948f9e..09c21da089 100644 --- a/tests/sessions_tests/tests.py +++ b/tests/sessions_tests/tests.py @@ -660,6 +660,16 @@ class SessionMiddlewareTests(TestCase): str(response.cookies[settings.SESSION_COOKIE_NAME]) ) + @override_settings(SESSION_COOKIE_SAMESITE='Strict') + def test_samesite_session_cookie(self): + request = RequestFactory().get('/') + response = HttpResponse() + middleware = SessionMiddleware() + middleware.process_request(request) + request.session['hello'] = 'world' + response = middleware.process_response(request, response) + self.assertEqual(response.cookies[settings.SESSION_COOKIE_NAME]['samesite'], 'Strict') + @override_settings(SESSION_COOKIE_HTTPONLY=False) def test_no_httponly_session_cookie(self): request = RequestFactory().get('/')