From ddf169cdaca91e92dd5bfe6796bb6f38369ecb68 Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Thu, 30 Jun 2016 18:42:11 +0200 Subject: [PATCH] Refs #16859 -- Allowed storing CSRF tokens in sessions. Major thanks to Shai for helping to refactor the tests, and to Shai, Tim, Florian, and others for extensive and helpful review. --- django/conf/global_settings.py | 1 + django/middleware/csrf.py | 79 ++++-- docs/ref/csrf.txt | 29 +- docs/ref/middleware.txt | 3 + docs/ref/settings.txt | 17 ++ docs/releases/1.11.txt | 3 +- tests/csrf_tests/tests.py | 493 ++++++++++++++++++++------------- 7 files changed, 407 insertions(+), 218 deletions(-) diff --git a/django/conf/global_settings.py b/django/conf/global_settings.py index b123c1136b..206d66f15d 100644 --- a/django/conf/global_settings.py +++ b/django/conf/global_settings.py @@ -548,6 +548,7 @@ CSRF_COOKIE_SECURE = False CSRF_COOKIE_HTTPONLY = False CSRF_HEADER_NAME = 'HTTP_X_CSRFTOKEN' CSRF_TRUSTED_ORIGINS = [] +CSRF_USE_SESSIONS = False ############ # MESSAGES # diff --git a/django/middleware/csrf.py b/django/middleware/csrf.py index 3f9a649c27..d7359e4912 100644 --- a/django/middleware/csrf.py +++ b/django/middleware/csrf.py @@ -11,6 +11,7 @@ import re import string from django.conf import settings +from django.core.exceptions import ImproperlyConfigured from django.urls import get_callable from django.utils.cache import patch_vary_headers from django.utils.crypto import constant_time_compare, get_random_string @@ -32,6 +33,7 @@ REASON_INSECURE_REFERER = "Referer checking failed - Referer is insecure while h CSRF_SECRET_LENGTH = 32 CSRF_TOKEN_LENGTH = 2 * CSRF_SECRET_LENGTH CSRF_ALLOWED_CHARS = string.ascii_letters + string.digits +CSRF_SESSION_KEY = '_csrftoken' def _get_failure_view(): @@ -160,20 +162,51 @@ class CsrfViewMiddleware(MiddlewareMixin): ) return _get_failure_view()(request, reason=reason) - def process_view(self, request, callback, callback_args, callback_kwargs): - if getattr(request, 'csrf_processing_done', False): - return None - - try: - cookie_token = request.COOKIES[settings.CSRF_COOKIE_NAME] - except KeyError: - csrf_token = None + def _get_token(self, request): + if settings.CSRF_USE_SESSIONS: + try: + return request.session.get(CSRF_SESSION_KEY) + except AttributeError: + raise ImproperlyConfigured( + 'CSRF_USE_SESSIONS is enabled, but request.session is not ' + 'set. SessionMiddleware must appear before CsrfViewMiddleware ' + 'in MIDDLEWARE%s.' % ('_CLASSES' if settings.MIDDLEWARE is None else '') + ) else: + try: + cookie_token = request.COOKIES[settings.CSRF_COOKIE_NAME] + except KeyError: + return None + csrf_token = _sanitize_token(cookie_token) if csrf_token != cookie_token: # Cookie token needed to be replaced; # the cookie needs to be reset. request.csrf_cookie_needs_reset = True + return csrf_token + + def _set_token(self, request, response): + if settings.CSRF_USE_SESSIONS: + request.session[CSRF_SESSION_KEY] = request.META['CSRF_COOKIE'] + else: + response.set_cookie( + settings.CSRF_COOKIE_NAME, + request.META['CSRF_COOKIE'], + max_age=settings.CSRF_COOKIE_AGE, + domain=settings.CSRF_COOKIE_DOMAIN, + path=settings.CSRF_COOKIE_PATH, + secure=settings.CSRF_COOKIE_SECURE, + httponly=settings.CSRF_COOKIE_HTTPONLY, + ) + # Set the Vary header since content varies with the CSRF cookie. + patch_vary_headers(response, ('Cookie',)) + + def process_view(self, request, callback, callback_args, callback_kwargs): + if getattr(request, 'csrf_processing_done', False): + return None + + csrf_token = self._get_token(request) + if csrf_token is not None: # Use same token next time. request.META['CSRF_COOKIE'] = csrf_token @@ -226,16 +259,21 @@ class CsrfViewMiddleware(MiddlewareMixin): if referer.scheme != 'https': return self._reject(request, REASON_INSECURE_REFERER) - # If there isn't a CSRF_COOKIE_DOMAIN, assume we need an exact - # match on host:port. If not, obey the cookie rules. - if settings.CSRF_COOKIE_DOMAIN is None: - # request.get_host() includes the port. - good_referer = request.get_host() - else: - good_referer = settings.CSRF_COOKIE_DOMAIN + # If there isn't a CSRF_COOKIE_DOMAIN, require an exact match + # match on host:port. If not, obey the cookie rules (or those + # for the session cookie, if CSRF_USE_SESSIONS). + good_referer = ( + settings.SESSION_COOKIE_DOMAIN + if settings.CSRF_USE_SESSIONS + else settings.CSRF_COOKIE_DOMAIN + ) + if good_referer is not None: server_port = request.get_port() if server_port not in ('443', '80'): good_referer = '%s:%s' % (good_referer, server_port) + else: + # request.get_host() includes the port. + good_referer = request.get_host() # Here we generate a list of all acceptable HTTP referers, # including the current host since that has been validated @@ -287,15 +325,6 @@ class CsrfViewMiddleware(MiddlewareMixin): # Set the CSRF cookie even if it's already set, so we renew # the expiry timer. - response.set_cookie(settings.CSRF_COOKIE_NAME, - request.META["CSRF_COOKIE"], - max_age=settings.CSRF_COOKIE_AGE, - domain=settings.CSRF_COOKIE_DOMAIN, - path=settings.CSRF_COOKIE_PATH, - secure=settings.CSRF_COOKIE_SECURE, - httponly=settings.CSRF_COOKIE_HTTPONLY - ) - # Content varies with the CSRF cookie, so set the Vary header. - patch_vary_headers(response, ('Cookie',)) + self._set_token(request, response) response.csrf_cookie_set = True return response diff --git a/docs/ref/csrf.txt b/docs/ref/csrf.txt index 2c1b8abbf5..3d1ecc1237 100644 --- a/docs/ref/csrf.txt +++ b/docs/ref/csrf.txt @@ -64,9 +64,14 @@ XMLHttpRequest, set a custom ``X-CSRFToken`` header to the value of the CSRF token. This is often easier, because many JavaScript frameworks provide hooks that allow headers to be set on every request. -As a first step, you must get the CSRF token itself. The recommended source for -the token is the ``csrftoken`` cookie, which will be set if you've enabled CSRF -protection for your views as outlined above. +First, you must get the CSRF token. How to do that depends on whether or not +the :setting:`CSRF_USE_SESSIONS` setting is enabled. + +Acquiring the token if :setting:`CSRF_USE_SESSIONS` is ``False`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The recommended source for the token is the ``csrftoken`` cookie, which will be +set if you've enabled CSRF protection for your views as outlined above. .. note:: @@ -121,6 +126,23 @@ The above code could be simplified by using the `JavaScript Cookie library Django provides a view decorator which forces setting of the cookie: :func:`~django.views.decorators.csrf.ensure_csrf_cookie`. +Acquiring the token if :setting:`CSRF_USE_SESSIONS` is ``True`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +If you activate :setting:`CSRF_USE_SESSIONS`, you must include the CSRF token +in your HTML and read the token from the DOM with JavaScript: + +.. code-block:: html+django + + {% csrf_token %} + + +Setting the token on the AJAX request +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + Finally, you'll have to actually set the header on your AJAX request, while protecting the CSRF token from being sent to other domains using `settings.crossDomain `_ in jQuery 1.5.1 and @@ -493,6 +515,7 @@ A number of settings can be used to control Django's CSRF behavior: * :setting:`CSRF_FAILURE_VIEW` * :setting:`CSRF_HEADER_NAME` * :setting:`CSRF_TRUSTED_ORIGINS` +* :setting:`CSRF_USE_SESSIONS` Frequently Asked Questions ========================== diff --git a/docs/ref/middleware.txt b/docs/ref/middleware.txt index 94778d8a3e..dd0bae3bb3 100644 --- a/docs/ref/middleware.txt +++ b/docs/ref/middleware.txt @@ -512,6 +512,9 @@ Here are some hints about the ordering of various Django middleware classes: Before any view middleware that assumes that CSRF attacks have been dealt with. + It must come after ``SessionMiddleware`` if you're using + :setting:`CSRF_USE_SESSIONS`. + #. :class:`~django.contrib.auth.middleware.AuthenticationMiddleware` After ``SessionMiddleware``: uses session storage. diff --git a/docs/ref/settings.txt b/docs/ref/settings.txt index 5f669115ab..87dbc89584 100644 --- a/docs/ref/settings.txt +++ b/docs/ref/settings.txt @@ -377,6 +377,22 @@ Whether to use a secure cookie for the CSRF cookie. If this is set to ``True``, the cookie will be marked as "secure," which means browsers may ensure that the cookie is only sent with an HTTPS connection. +.. setting:: CSRF_USE_SESSIONS + +``CSRF_USE_SESSIONS`` +--------------------- + +.. versionadded:: 1.11 + +Default: ``False`` + +Whether to store the CSRF token in the user's session instead of in a cookie. +It requires the use of :mod:`django.contrib.sessions`. + +Storing the CSRF token in a cookie (Django's default) is safe, but storing it +in the session is common practice in other web frameworks and therefore +sometimes demanded by security auditors. + .. setting:: CSRF_FAILURE_VIEW ``CSRF_FAILURE_VIEW`` @@ -3407,6 +3423,7 @@ Security * :setting:`CSRF_FAILURE_VIEW` * :setting:`CSRF_HEADER_NAME` * :setting:`CSRF_TRUSTED_ORIGINS` + * :setting:`CSRF_USE_SESSIONS` * :setting:`SECRET_KEY` * :setting:`X_FRAME_OPTIONS` diff --git a/docs/releases/1.11.txt b/docs/releases/1.11.txt index fc4a5cc3f5..acc2fe10e2 100644 --- a/docs/releases/1.11.txt +++ b/docs/releases/1.11.txt @@ -231,7 +231,8 @@ Cache CSRF ~~~~ -* ... +* Added the :setting:`CSRF_USE_SESSIONS` setting to allow storing the CSRF + token in the user's session rather than in a cookie. Database backends ~~~~~~~~~~~~~~~~~ diff --git a/tests/csrf_tests/tests.py b/tests/csrf_tests/tests.py index cd7ea8fbc6..6369de9f99 100644 --- a/tests/csrf_tests/tests.py +++ b/tests/csrf_tests/tests.py @@ -6,10 +6,12 @@ import re import warnings from django.conf import settings +from django.core.exceptions import ImproperlyConfigured from django.http import HttpRequest, HttpResponse from django.middleware.csrf import ( - CSRF_TOKEN_LENGTH, REASON_BAD_TOKEN, REASON_NO_CSRF_COOKIE, - CsrfViewMiddleware, _compare_salted_tokens as equivalent_tokens, get_token, + CSRF_SESSION_KEY, CSRF_TOKEN_LENGTH, REASON_BAD_TOKEN, + REASON_NO_CSRF_COOKIE, CsrfViewMiddleware, + _compare_salted_tokens as equivalent_tokens, get_token, ) from django.template import RequestContext, Template from django.template.context_processors import csrf @@ -58,20 +60,27 @@ class TestingHttpRequest(HttpRequest): A version of HttpRequest that allows us to change some things more easily """ + def __init__(self): + super(TestingHttpRequest, self).__init__() + # A real session backend isn't needed. + self.session = {} + def is_secure(self): return getattr(self, '_is_secure_override', False) -class CsrfViewMiddlewareTest(SimpleTestCase): +class CsrfViewMiddlewareTestMixin(object): + """ + Shared methods and tests for session-based and cookie-based tokens. + """ + _csrf_id = _csrf_id_cookie = '1bcdefghij2bcdefghij3bcdefghij4bcdefghij5bcdefghij6bcdefghijABCD' def _get_GET_no_csrf_cookie_request(self): return TestingHttpRequest() def _get_GET_csrf_cookie_request(self): - req = TestingHttpRequest() - req.COOKIES[settings.CSRF_COOKIE_NAME] = self._csrf_id_cookie - return req + raise NotImplementedError('This method must be implemented by a subclass.') def _get_POST_csrf_cookie_request(self): req = self._get_GET_csrf_cookie_request() @@ -88,16 +97,6 @@ class CsrfViewMiddlewareTest(SimpleTestCase): req.POST['csrfmiddlewaretoken'] = self._csrf_id return req - def _get_POST_bare_secret_csrf_cookie_request(self): - req = self._get_POST_no_csrf_cookie_request() - req.COOKIES[settings.CSRF_COOKIE_NAME] = self._csrf_id_cookie[:32] - return req - - def _get_POST_bare_secret_csrf_cookie_request_with_token(self): - req = self._get_POST_bare_secret_csrf_cookie_request() - req.POST['csrfmiddlewaretoken'] = self._csrf_id_cookie[:32] - return req - def _check_token_present(self, response, csrf_id=None): text = text_type(response.content, response.charset) match = re.search("name='csrfmiddlewaretoken' value='(.*?)'", text) @@ -107,77 +106,6 @@ class CsrfViewMiddlewareTest(SimpleTestCase): "Could not find csrfmiddlewaretoken to match %s" % csrf_token ) - def test_process_view_token_too_long(self): - """ - If the token is longer than expected, it is ignored and a new token is - created. - """ - req = self._get_GET_no_csrf_cookie_request() - req.COOKIES[settings.CSRF_COOKIE_NAME] = 'x' * 100000 - CsrfViewMiddleware().process_view(req, token_view, (), {}) - resp = token_view(req) - resp2 = CsrfViewMiddleware().process_response(req, resp) - csrf_cookie = resp2.cookies.get(settings.CSRF_COOKIE_NAME, False) - self.assertEqual(len(csrf_cookie.value), CSRF_TOKEN_LENGTH) - - def test_process_view_token_invalid_chars(self): - """ - If the token contains non-alphanumeric characters, it is ignored and a - new token is created. - """ - token = ('!@#' + self._csrf_id)[:CSRF_TOKEN_LENGTH] - req = self._get_GET_no_csrf_cookie_request() - req.COOKIES[settings.CSRF_COOKIE_NAME] = token - CsrfViewMiddleware().process_view(req, token_view, (), {}) - resp = token_view(req) - resp2 = CsrfViewMiddleware().process_response(req, resp) - csrf_cookie = resp2.cookies.get(settings.CSRF_COOKIE_NAME, False) - self.assertEqual(len(csrf_cookie.value), CSRF_TOKEN_LENGTH) - self.assertNotEqual(csrf_cookie.value, token) - - def test_process_view_token_invalid_bytes(self): - """ - If the token contains improperly encoded unicode, it is ignored and a - new token is created. - """ - token = (b"<1>\xc2\xa1" + force_bytes(self._csrf_id, 'ascii'))[:CSRF_TOKEN_LENGTH] - req = self._get_GET_no_csrf_cookie_request() - req.COOKIES[settings.CSRF_COOKIE_NAME] = token - # We expect a UnicodeWarning here, because we used broken utf-8 on purpose - with warnings.catch_warnings(): - warnings.filterwarnings("ignore", category=UnicodeWarning) - CsrfViewMiddleware().process_view(req, token_view, (), {}) - resp = token_view(req) - resp2 = CsrfViewMiddleware().process_response(req, resp) - csrf_cookie = resp2.cookies.get(settings.CSRF_COOKIE_NAME, False) - self.assertEqual(len(csrf_cookie.value), CSRF_TOKEN_LENGTH) - self.assertNotEqual(csrf_cookie.value, token) - - def test_process_response_get_token_used(self): - """ - When get_token is used, check that the cookie is created and headers - patched. - """ - req = self._get_GET_no_csrf_cookie_request() - - # Put tests for CSRF_COOKIE_* settings here - with self.settings(CSRF_COOKIE_NAME='myname', - CSRF_COOKIE_DOMAIN='.example.com', - CSRF_COOKIE_PATH='/test/', - CSRF_COOKIE_SECURE=True, - CSRF_COOKIE_HTTPONLY=True): - # token_view calls get_token() indirectly - CsrfViewMiddleware().process_view(req, token_view, (), {}) - resp = token_view(req) - resp2 = CsrfViewMiddleware().process_response(req, resp) - csrf_cookie = resp2.cookies.get('myname', False) - self.assertIsNot(csrf_cookie, False) - self.assertEqual(csrf_cookie['domain'], '.example.com') - self.assertIs(csrf_cookie['secure'], True) - self.assertIs(csrf_cookie['httponly'], True) - self.assertEqual(csrf_cookie['path'], '/test/') - self.assertIn('Cookie', resp2.get('Vary', '')) - def test_process_response_get_token_not_used(self): """ If get_token() is not called, the view middleware does not @@ -371,20 +299,6 @@ class CsrfViewMiddlewareTest(SimpleTestCase): "CSRF cookie was changed on an accepted request" ) - def test_bare_secret_accepted_and_replaced(self): - """ - The csrf token is reset from a bare secret. - """ - req = self._get_POST_bare_secret_csrf_cookie_request_with_token() - req2 = CsrfViewMiddleware().process_view(req, token_view, (), {}) - self.assertIsNone(req2) - resp = token_view(req) - resp = CsrfViewMiddleware().process_response(req, resp) - self.assertIn(settings.CSRF_COOKIE_NAME, resp.cookies, "Cookie was not reset from bare secret") - csrf_cookie = resp.cookies[settings.CSRF_COOKIE_NAME] - self.assertEqual(len(csrf_cookie.value), CSRF_TOKEN_LENGTH) - self._check_token_present(resp, csrf_id=csrf_cookie.value) - @override_settings(DEBUG=True, ALLOWED_HOSTS=['www.example.com']) def test_https_bad_referer(self): """ @@ -465,11 +379,7 @@ class CsrfViewMiddlewareTest(SimpleTestCase): req2 = CsrfViewMiddleware().process_view(req, post_form_view, (), {}) self.assertIsNone(req2) - @override_settings(ALLOWED_HOSTS=['www.example.com'], CSRF_COOKIE_DOMAIN='.example.com', USE_X_FORWARDED_PORT=True) - def test_https_good_referer_behind_proxy(self): - """ - A POST HTTPS request is accepted when USE_X_FORWARDED_PORT=True. - """ + def _test_https_good_referer_behind_proxy(self): req = self._get_POST_request_with_token() req._is_secure_override = True req.META.update({ @@ -508,12 +418,7 @@ class CsrfViewMiddlewareTest(SimpleTestCase): response = CsrfViewMiddleware().process_view(req, post_form_view, (), {}) self.assertIsNone(response) - @override_settings(ALLOWED_HOSTS=['www.example.com'], CSRF_COOKIE_DOMAIN='.example.com') - def test_https_good_referer_matches_cookie_domain(self): - """ - A POST HTTPS request with a good referer should be accepted from a - subdomain that's allowed by CSRF_COOKIE_DOMAIN. - """ + def _test_https_good_referer_matches_cookie_domain(self): req = self._get_POST_request_with_token() req._is_secure_override = True req.META['HTTP_REFERER'] = 'https://foo.example.com/' @@ -521,12 +426,7 @@ class CsrfViewMiddlewareTest(SimpleTestCase): response = CsrfViewMiddleware().process_view(req, post_form_view, (), {}) self.assertIsNone(response) - @override_settings(ALLOWED_HOSTS=['www.example.com'], CSRF_COOKIE_DOMAIN='.example.com') - def test_https_good_referer_matches_cookie_domain_with_different_port(self): - """ - A POST HTTPS request with a good referer should be accepted from a - subdomain that's allowed by CSRF_COOKIE_DOMAIN and a non-443 port. - """ + def _test_https_good_referer_matches_cookie_domain_with_different_port(self): req = self._get_POST_request_with_token() req._is_secure_override = True req.META['HTTP_HOST'] = 'www.example.com' @@ -535,21 +435,98 @@ class CsrfViewMiddlewareTest(SimpleTestCase): response = CsrfViewMiddleware().process_view(req, post_form_view, (), {}) self.assertIsNone(response) - @override_settings(CSRF_COOKIE_DOMAIN='.example.com', DEBUG=True) - def test_https_reject_insecure_referer(self): + def test_ensures_csrf_cookie_no_logging(self): """ - A POST HTTPS request from an insecure referer should be rejected. + ensure_csrf_cookie() doesn't log warnings (#19436). """ - req = self._get_POST_request_with_token() - req._is_secure_override = True - req.META['HTTP_REFERER'] = 'http://example.com/' - req.META['SERVER_PORT'] = '443' - response = CsrfViewMiddleware().process_view(req, post_form_view, (), {}) - self.assertContains( - response, - 'Referer checking failed - Referer is insecure while host is secure.', - status_code=403, - ) + @ensure_csrf_cookie + def view(request): + # Doesn't insert a token or anything + return HttpResponse(content="") + + class TestHandler(logging.Handler): + def emit(self, record): + raise Exception("This shouldn't have happened!") + + logger = logging.getLogger('django.request') + test_handler = TestHandler() + old_log_level = logger.level + try: + logger.addHandler(test_handler) + logger.setLevel(logging.WARNING) + + req = self._get_GET_no_csrf_cookie_request() + view(req) + finally: + logger.removeHandler(test_handler) + logger.setLevel(old_log_level) + + def test_post_data_read_failure(self): + """ + #20128 -- IOErrors during POST data reading should be caught and + treated as if the POST data wasn't there. + """ + class CsrfPostRequest(HttpRequest): + """ + HttpRequest that can raise an IOError when accessing POST data + """ + def __init__(self, token, raise_error): + super(CsrfPostRequest, self).__init__() + self.method = 'POST' + + self.raise_error = False + self.COOKIES[settings.CSRF_COOKIE_NAME] = token + + # Handle both cases here to prevent duplicate code in the + # session tests. + self.session = {} + self.session[CSRF_SESSION_KEY] = token + + self.POST['csrfmiddlewaretoken'] = token + self.raise_error = raise_error + + def _load_post_and_files(self): + raise IOError('error reading input data') + + def _get_post(self): + if self.raise_error: + self._load_post_and_files() + return self._post + + def _set_post(self, post): + self._post = post + + POST = property(_get_post, _set_post) + + token = ('ABC' + self._csrf_id)[:CSRF_TOKEN_LENGTH] + + req = CsrfPostRequest(token, raise_error=False) + resp = CsrfViewMiddleware().process_view(req, post_form_view, (), {}) + self.assertIsNone(resp) + + req = CsrfPostRequest(token, raise_error=True) + with patch_logger('django.security.csrf', 'warning') as logger_calls: + resp = CsrfViewMiddleware().process_view(req, post_form_view, (), {}) + self.assertEqual(resp.status_code, 403) + self.assertEqual(logger_calls[0], 'Forbidden (%s): ' % REASON_BAD_TOKEN) + + +class CsrfViewMiddlewareTests(CsrfViewMiddlewareTestMixin, SimpleTestCase): + + def _get_GET_csrf_cookie_request(self): + req = TestingHttpRequest() + req.COOKIES[settings.CSRF_COOKIE_NAME] = self._csrf_id_cookie + return req + + def _get_POST_bare_secret_csrf_cookie_request(self): + req = self._get_POST_no_csrf_cookie_request() + req.COOKIES[settings.CSRF_COOKIE_NAME] = self._csrf_id_cookie[:32] + return req + + def _get_POST_bare_secret_csrf_cookie_request_with_token(self): + req = self._get_POST_bare_secret_csrf_cookie_request() + req.POST['csrfmiddlewaretoken'] = self._csrf_id_cookie[:32] + return req def test_ensures_csrf_cookie_no_middleware(self): """ @@ -582,32 +559,6 @@ class CsrfViewMiddlewareTest(SimpleTestCase): self.assertTrue(resp2.cookies.get(settings.CSRF_COOKIE_NAME, False)) self.assertIn('Cookie', resp2.get('Vary', '')) - def test_ensures_csrf_cookie_no_logging(self): - """ - ensure_csrf_cookie() doesn't log warnings (#19436). - """ - @ensure_csrf_cookie - def view(request): - # Doesn't insert a token or anything - return HttpResponse(content="") - - class TestHandler(logging.Handler): - def emit(self, record): - raise Exception("This shouldn't have happened!") - - logger = logging.getLogger('django.request') - test_handler = TestHandler() - old_log_level = logger.level - try: - logger.addHandler(test_handler) - logger.setLevel(logging.WARNING) - - req = self._get_GET_no_csrf_cookie_request() - view(req) - finally: - logger.removeHandler(test_handler) - logger.setLevel(old_log_level) - def test_csrf_cookie_age(self): """ CSRF cookie age can be set using settings.CSRF_COOKIE_AGE. @@ -651,45 +602,209 @@ class CsrfViewMiddlewareTest(SimpleTestCase): max_age = resp2.cookies.get('csrfcookie').get('max-age') self.assertEqual(max_age, '') - def test_post_data_read_failure(self): + def test_process_view_token_too_long(self): """ - #20128 -- IOErrors during POST data reading should be caught and - treated as if the POST data wasn't there. + If the token is longer than expected, it is ignored and a new token is + created. """ - class CsrfPostRequest(HttpRequest): - """ - HttpRequest that can raise an IOError when accessing POST data - """ - def __init__(self, token, raise_error): - super(CsrfPostRequest, self).__init__() - self.method = 'POST' + req = self._get_GET_no_csrf_cookie_request() + req.COOKIES[settings.CSRF_COOKIE_NAME] = 'x' * 100000 + CsrfViewMiddleware().process_view(req, token_view, (), {}) + resp = token_view(req) + resp2 = CsrfViewMiddleware().process_response(req, resp) + csrf_cookie = resp2.cookies.get(settings.CSRF_COOKIE_NAME, False) + self.assertEqual(len(csrf_cookie.value), CSRF_TOKEN_LENGTH) - self.raise_error = False - self.COOKIES[settings.CSRF_COOKIE_NAME] = token - self.POST['csrfmiddlewaretoken'] = token - self.raise_error = raise_error + def test_process_view_token_invalid_bytes(self): + """ + If the token contains improperly encoded unicode, it is ignored and a + new token is created. + """ + token = (b"<1>\xc2\xa1" + force_bytes(self._csrf_id, 'ascii'))[:CSRF_TOKEN_LENGTH] + req = self._get_GET_no_csrf_cookie_request() + req.COOKIES[settings.CSRF_COOKIE_NAME] = token + # We expect a UnicodeWarning here, because we used broken utf-8 on purpose + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", category=UnicodeWarning) + CsrfViewMiddleware().process_view(req, token_view, (), {}) + resp = token_view(req) + resp2 = CsrfViewMiddleware().process_response(req, resp) + csrf_cookie = resp2.cookies.get(settings.CSRF_COOKIE_NAME, False) + self.assertEqual(len(csrf_cookie.value), CSRF_TOKEN_LENGTH) + self.assertNotEqual(csrf_cookie.value, token) - def _load_post_and_files(self): - raise IOError('error reading input data') + def test_process_view_token_invalid_chars(self): + """ + If the token contains non-alphanumeric characters, it is ignored and a + new token is created. + """ + token = ('!@#' + self._csrf_id)[:CSRF_TOKEN_LENGTH] + req = self._get_GET_no_csrf_cookie_request() + req.COOKIES[settings.CSRF_COOKIE_NAME] = token + CsrfViewMiddleware().process_view(req, token_view, (), {}) + resp = token_view(req) + resp2 = CsrfViewMiddleware().process_response(req, resp) + csrf_cookie = resp2.cookies.get(settings.CSRF_COOKIE_NAME, False) + self.assertEqual(len(csrf_cookie.value), CSRF_TOKEN_LENGTH) + self.assertNotEqual(csrf_cookie.value, token) - def _get_post(self): - if self.raise_error: - self._load_post_and_files() - return self._post + def test_bare_secret_accepted_and_replaced(self): + """ + The csrf token is reset from a bare secret. + """ + req = self._get_POST_bare_secret_csrf_cookie_request_with_token() + req2 = CsrfViewMiddleware().process_view(req, token_view, (), {}) + self.assertIsNone(req2) + resp = token_view(req) + resp = CsrfViewMiddleware().process_response(req, resp) + self.assertIn(settings.CSRF_COOKIE_NAME, resp.cookies, "Cookie was not reset from bare secret") + csrf_cookie = resp.cookies[settings.CSRF_COOKIE_NAME] + self.assertEqual(len(csrf_cookie.value), CSRF_TOKEN_LENGTH) + self._check_token_present(resp, csrf_id=csrf_cookie.value) - def _set_post(self, post): - self._post = post + @override_settings(ALLOWED_HOSTS=['www.example.com'], CSRF_COOKIE_DOMAIN='.example.com', USE_X_FORWARDED_PORT=True) + def test_https_good_referer_behind_proxy(self): + """ + A POST HTTPS request is accepted when USE_X_FORWARDED_PORT=True. + """ + self._test_https_good_referer_behind_proxy() - POST = property(_get_post, _set_post) + @override_settings(ALLOWED_HOSTS=['www.example.com'], CSRF_COOKIE_DOMAIN='.example.com') + def test_https_good_referer_matches_cookie_domain(self): + """ + A POST HTTPS request with a good referer should be accepted from a + subdomain that's allowed by CSRF_COOKIE_DOMAIN. + """ + self._test_https_good_referer_matches_cookie_domain() - token = ('ABC' + self._csrf_id)[:CSRF_TOKEN_LENGTH] + @override_settings(ALLOWED_HOSTS=['www.example.com'], CSRF_COOKIE_DOMAIN='.example.com') + def test_https_good_referer_matches_cookie_domain_with_different_port(self): + """ + A POST HTTPS request with a good referer should be accepted from a + subdomain that's allowed by CSRF_COOKIE_DOMAIN and a non-443 port. + """ + self._test_https_good_referer_matches_cookie_domain_with_different_port() - req = CsrfPostRequest(token, raise_error=False) - resp = CsrfViewMiddleware().process_view(req, post_form_view, (), {}) - self.assertIsNone(resp) + @override_settings(CSRF_COOKIE_DOMAIN='.example.com', DEBUG=True) + def test_https_reject_insecure_referer(self): + """ + A POST HTTPS request from an insecure referer should be rejected. + """ + req = self._get_POST_request_with_token() + req._is_secure_override = True + req.META['HTTP_REFERER'] = 'http://example.com/' + req.META['SERVER_PORT'] = '443' + response = CsrfViewMiddleware().process_view(req, post_form_view, (), {}) + self.assertContains( + response, + 'Referer checking failed - Referer is insecure while host is secure.', + status_code=403, + ) - req = CsrfPostRequest(token, raise_error=True) - with patch_logger('django.security.csrf', 'warning') as logger_calls: - resp = CsrfViewMiddleware().process_view(req, post_form_view, (), {}) - self.assertEqual(resp.status_code, 403) - self.assertEqual(logger_calls[0], 'Forbidden (%s): ' % REASON_BAD_TOKEN) + +@override_settings(CSRF_USE_SESSIONS=True, CSRF_COOKIE_DOMAIN=None) +class CsrfViewMiddlewareUseSessionsTests(CsrfViewMiddlewareTestMixin, SimpleTestCase): + """ + CSRF tests with CSRF_USE_SESSIONS=True. + """ + + def _get_POST_bare_secret_csrf_cookie_request(self): + req = self._get_POST_no_csrf_cookie_request() + req.session[CSRF_SESSION_KEY] = self._csrf_id_cookie[:32] + return req + + def _get_GET_csrf_cookie_request(self): + req = TestingHttpRequest() + req.session[CSRF_SESSION_KEY] = self._csrf_id_cookie + return req + + def test_no_session_on_request(self): + msg = ( + 'CSRF_USE_SESSIONS is enabled, but request.session is not set. ' + 'SessionMiddleware must appear before CsrfViewMiddleware in MIDDLEWARE.' + ) + with self.assertRaisesMessage(ImproperlyConfigured, msg): + CsrfViewMiddleware().process_view(HttpRequest(), None, (), {}) + + def test_process_response_get_token_used(self): + """The ensure_csrf_cookie() decorator works without middleware.""" + @ensure_csrf_cookie + def view(request): + # Doesn't insert a token or anything + return HttpResponse(content="") + + req = self._get_GET_no_csrf_cookie_request() + view(req) + self.assertTrue(req.session.get(CSRF_SESSION_KEY, False)) + + def test_ensures_csrf_cookie_with_middleware(self): + """ + The ensure_csrf_cookie() decorator works with the CsrfViewMiddleware + enabled. + """ + @ensure_csrf_cookie + def view(request): + # Doesn't insert a token or anything + return HttpResponse(content="") + + req = self._get_GET_no_csrf_cookie_request() + CsrfViewMiddleware().process_view(req, view, (), {}) + resp = view(req) + CsrfViewMiddleware().process_response(req, resp) + self.assertTrue(req.session.get(CSRF_SESSION_KEY, False)) + + def test_token_node_with_new_csrf_cookie(self): + """ + CsrfTokenNode works when a CSRF cookie is created by the middleware + (when one was not already present). + """ + req = self._get_GET_no_csrf_cookie_request() + CsrfViewMiddleware().process_view(req, token_view, (), {}) + resp = token_view(req) + CsrfViewMiddleware().process_response(req, resp) + csrf_cookie = req.session[CSRF_SESSION_KEY] + self._check_token_present(resp, csrf_id=csrf_cookie) + + @override_settings( + ALLOWED_HOSTS=['www.example.com'], + SESSION_COOKIE_DOMAIN='.example.com', + USE_X_FORWARDED_PORT=True, + DEBUG=True, + ) + def test_https_good_referer_behind_proxy(self): + """ + A POST HTTPS request is accepted when USE_X_FORWARDED_PORT=True. + """ + self._test_https_good_referer_behind_proxy() + + @override_settings(ALLOWED_HOSTS=['www.example.com'], SESSION_COOKIE_DOMAIN='.example.com') + def test_https_good_referer_matches_cookie_domain(self): + """ + A POST HTTPS request with a good referer should be accepted from a + subdomain that's allowed by SESSION_COOKIE_DOMAIN. + """ + self._test_https_good_referer_matches_cookie_domain() + + @override_settings(ALLOWED_HOSTS=['www.example.com'], SESSION_COOKIE_DOMAIN='.example.com') + def test_https_good_referer_matches_cookie_domain_with_different_port(self): + """ + A POST HTTPS request with a good referer should be accepted from a + subdomain that's allowed by SESSION_COOKIE_DOMAIN and a non-443 port. + """ + self._test_https_good_referer_matches_cookie_domain_with_different_port() + + @override_settings(SESSION_COOKIE_DOMAIN='.example.com', DEBUG=True) + def test_https_reject_insecure_referer(self): + """ + A POST HTTPS request from an insecure referer should be rejected. + """ + req = self._get_POST_request_with_token() + req._is_secure_override = True + req.META['HTTP_REFERER'] = 'http://example.com/' + req.META['SERVER_PORT'] = '443' + response = CsrfViewMiddleware().process_view(req, post_form_view, (), {}) + self.assertContains( + response, + 'Referer checking failed - Referer is insecure while host is secure.', + status_code=403, + )