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

Thanks Alex Gaynor for contributing to the patch.
This commit is contained in:
Alex Gaynor 2018-04-13 20:58:31 -04:00 committed by Tim Graham
parent 13efbb233a
commit 9a56b4b13e
16 changed files with 134 additions and 5 deletions

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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):
"""

View File

@ -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."""

View File

@ -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',))

View File

@ -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`

View File

@ -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

View File

@ -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`

View File

@ -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
-------------

View File

@ -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`

View File

@ -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

View File

@ -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")

View File

@ -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()

View File

@ -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):

View File

@ -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('/')