summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--django/conf/global_settings.py4
-rw-r--r--django/contrib/messages/storage/cookie.py1
-rw-r--r--django/contrib/sessions/middleware.py1
-rw-r--r--django/http/cookie.py3
-rw-r--r--django/http/response.py6
-rw-r--r--django/middleware/csrf.py1
-rw-r--r--docs/ref/csrf.txt1
-rw-r--r--docs/ref/request-response.txt13
-rw-r--r--docs/ref/settings.txt53
-rw-r--r--docs/releases/2.1.txt19
-rw-r--r--docs/topics/http/sessions.txt1
-rw-r--r--tests/csrf_tests/tests.py8
-rw-r--r--tests/httpwrappers/tests.py5
-rw-r--r--tests/messages_tests/test_cookie.py2
-rw-r--r--tests/responses/test_cookie.py11
-rw-r--r--tests/sessions_tests/tests.py10
16 files changed, 134 insertions, 5 deletions
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 </topics/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('/')