Browse Source

Fixed #27863 -- Added support for the SameSite cookie flag.

Thanks Alex Gaynor for contributing to the patch.
Alex Gaynor 7 years ago
parent
commit
9a56b4b13e

+ 4 - 0
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

+ 1 - 0
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)

+ 1 - 0
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

+ 3 - 0
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):
     """

+ 5 - 1
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."""

+ 1 - 0
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',))

+ 1 - 0
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`

+ 11 - 2
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 </topics/signing>` the cookie before setting

+ 53 - 0
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`

+ 17 - 2
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
 -------------
 

+ 1 - 0
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`

+ 8 - 0
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

+ 5 - 0
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")

+ 2 - 0
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()

+ 11 - 0
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):
 

+ 10 - 0
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('/')