From 2411b8b5eb65fe3d7bcc1ee1f59e2433520c7df6 Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Sat, 2 Jan 2021 18:46:17 -0500 Subject: [PATCH] Fixed #16010 -- Added Origin header checking to CSRF middleware. Thanks David Benjamin for the original patch, and Florian Apolloner, Chris Jerdonek, and Adam Johnson for reviews. --- django/middleware/csrf.py | 51 ++++++++++++- docs/ref/csrf.txt | 18 ++++- docs/ref/settings.txt | 19 +++-- docs/releases/4.0.txt | 13 +++- tests/csrf_tests/tests.py | 150 +++++++++++++++++++++++++++++++++++++- 5 files changed, 238 insertions(+), 13 deletions(-) diff --git a/django/middleware/csrf.py b/django/middleware/csrf.py index 10d678db41e..a17dde9276d 100644 --- a/django/middleware/csrf.py +++ b/django/middleware/csrf.py @@ -7,6 +7,7 @@ against request forgeries from other sites. import logging import re import string +from collections import defaultdict from urllib.parse import urlparse from django.conf import settings @@ -21,6 +22,7 @@ from django.utils.log import log_response logger = logging.getLogger('django.security.csrf') +REASON_BAD_ORIGIN = "Origin checking failed - %s does not match any trusted origins." REASON_NO_REFERER = "Referer checking failed - no Referer." REASON_BAD_REFERER = "Referer checking failed - %s does not match any trusted origins." REASON_NO_CSRF_COOKIE = "CSRF cookie not set." @@ -144,6 +146,24 @@ class CsrfViewMiddleware(MiddlewareMixin): for origin in settings.CSRF_TRUSTED_ORIGINS ] + @cached_property + def allowed_origins_exact(self): + return { + origin for origin in settings.CSRF_TRUSTED_ORIGINS + if '*' not in origin + } + + @cached_property + def allowed_origin_subdomains(self): + """ + A mapping of allowed schemes to list of allowed netlocs, where all + subdomains of the netloc are allowed. + """ + allowed_origin_subdomains = defaultdict(list) + for parsed in (urlparse(origin) for origin in settings.CSRF_TRUSTED_ORIGINS if '*' in origin): + allowed_origin_subdomains[parsed.scheme].append(parsed.netloc.lstrip('*')) + return allowed_origin_subdomains + # The _accept and _reject methods currently only exist for the sake of the # requires_csrf_token decorator. def _accept(self, request): @@ -204,6 +224,27 @@ class CsrfViewMiddleware(MiddlewareMixin): # Set the Vary header since content varies with the CSRF cookie. patch_vary_headers(response, ('Cookie',)) + def _origin_verified(self, request): + request_origin = request.META['HTTP_ORIGIN'] + good_origin = '%s://%s' % ( + 'https' if request.is_secure() else 'http', + request.get_host(), + ) + if request_origin == good_origin: + return True + if request_origin in self.allowed_origins_exact: + return True + try: + parsed_origin = urlparse(request_origin) + except ValueError: + return False + request_scheme = parsed_origin.scheme + request_netloc = parsed_origin.netloc + return any( + is_same_domain(request_netloc, host) + for host in self.allowed_origin_subdomains.get(request_scheme, ()) + ) + def process_request(self, request): csrf_token = self._get_token(request) if csrf_token is not None: @@ -229,7 +270,15 @@ class CsrfViewMiddleware(MiddlewareMixin): # branches that call reject(). return self._accept(request) - if request.is_secure(): + # Reject the request if the Origin header doesn't match an allowed + # value. + if 'HTTP_ORIGIN' in request.META: + if not self._origin_verified(request): + return self._reject(request, REASON_BAD_ORIGIN % request.META['HTTP_ORIGIN']) + elif request.is_secure(): + # If the Origin header wasn't provided, reject HTTPS requests + # if the Referer header doesn't match an allowed value. + # # Suppose user visits http://example.com/ # An active network attacker (man-in-the-middle, MITM) sends a # POST form that targets https://example.com/detonate-bomb/ and diff --git a/docs/ref/csrf.txt b/docs/ref/csrf.txt index 175cbb7da0f..6e340bcdeb1 100644 --- a/docs/ref/csrf.txt +++ b/docs/ref/csrf.txt @@ -263,10 +263,15 @@ The CSRF protection is based on the following things: This check is done by ``CsrfViewMiddleware``. -#. In addition, for HTTPS requests, strict referer checking is done by - ``CsrfViewMiddleware``. This means that even if a subdomain can set or - modify cookies on your domain, it can't force a user to post to your - application since that request won't come from your own exact domain. +#. ``CsrfViewMiddleware`` verifies the `Origin header`_, if provided by the + browser, against the current host and the :setting:`CSRF_TRUSTED_ORIGINS` + setting. This provides protection against cross-subdomain attacks. + +#. In addition, for HTTPS requests, if the ``Origin`` header isn't provided, + ``CsrfViewMiddleware`` performs strict referer checking. This means that + even if a subdomain can set or modify cookies on your domain, it can't force + a user to post to your application since that request won't come from your + own exact domain. This also addresses a man-in-the-middle attack that's possible under HTTPS when using a session independent secret, due to the fact that HTTP @@ -284,6 +289,10 @@ The CSRF protection is based on the following things: Expanding the accepted referers beyond the current host or cookie domain can be done with the :setting:`CSRF_TRUSTED_ORIGINS` setting. +.. versionadded:: 4.0 + + ``Origin`` checking was added, as described above. + This ensures that only forms that have originated from trusted domains can be used to POST data back. @@ -314,6 +323,7 @@ vulnerability allows and much worse). sites. .. _BREACH: http://breachattack.com/ +.. _Origin header: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Origin .. _disable the referer: https://www.w3.org/TR/referrer-policy/#referrer-policy-delivery Caching diff --git a/docs/ref/settings.txt b/docs/ref/settings.txt index 704bee63a42..5c938f26f1b 100644 --- a/docs/ref/settings.txt +++ b/docs/ref/settings.txt @@ -459,13 +459,18 @@ Default: ``[]`` (Empty list) A list of trusted origins for unsafe requests (e.g. ``POST``). +For requests that include the ``Origin`` header, Django's CSRF protection +requires that header match the origin present in the ``Host`` header. + For a :meth:`secure ` unsafe -request, Django's CSRF protection requires that the request have a ``Referer`` -header that matches the origin present in the ``Host`` header. This prevents, -for example, a ``POST`` request from ``subdomain.example.com`` from succeeding -against ``api.example.com``. If you need cross-origin unsafe requests over -HTTPS, continuing the example, add ``'https://subdomain.example.com'`` to this -list (and/or ``http://...`` if requests originate from an insecure page). +request that doesn't include the ``Origin`` header, the request must have a +``Referer`` header that matches the origin present in the ``Host`` header. + +These checks prevent, for example, a ``POST`` request from +``subdomain.example.com`` from succeeding against ``api.example.com``. If you +need cross-origin unsafe requests, continuing the example, add +``'https://subdomain.example.com'`` to this list (and/or ``http://...`` if +requests originate from an insecure page). The setting also supports subdomains, so you could add ``'https://*.example.com'``, for example, to allow access from all subdomains @@ -476,6 +481,8 @@ of ``example.com``. The values in older versions must only include the hostname (possibly with a leading dot) and not the scheme or an asterisk. + Also, ``Origin`` header checking isn't performed in older versions. + .. setting:: DATABASES ``DATABASES`` diff --git a/docs/releases/4.0.txt b/docs/releases/4.0.txt index e4107fdad11..56197115c6a 100644 --- a/docs/releases/4.0.txt +++ b/docs/releases/4.0.txt @@ -149,7 +149,9 @@ Cache CSRF ~~~~ -* ... +* CSRF protection now consults the ``Origin`` header, if present. To facilitate + this, :ref:`some changes ` to the + :setting:`CSRF_TRUSTED_ORIGINS` setting are required. Decorators ~~~~~~~~~~ @@ -323,6 +325,15 @@ the dot. For example, change ``'.example.com'`` to ``'https://*.example.com'``. A system check detects any required changes. +Configuring it may now be required +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +As CSRF protection now consults the ``Origin`` header, you may need to set +:setting:`CSRF_TRUSTED_ORIGINS`, particularly if you allow requests from +subdomains by setting :setting:`CSRF_COOKIE_DOMAIN` (or +:setting:`SESSION_COOKIE_DOMAIN` if :setting:`CSRF_USE_SESSIONS` is enabled) to +a value starting with a dot. + Miscellaneous ------------- diff --git a/tests/csrf_tests/tests.py b/tests/csrf_tests/tests.py index f733d25b029..fb6168a044a 100644 --- a/tests/csrf_tests/tests.py +++ b/tests/csrf_tests/tests.py @@ -5,7 +5,7 @@ from django.contrib.sessions.backends.cache import SessionStore from django.core.exceptions import ImproperlyConfigured from django.http import HttpRequest, HttpResponse from django.middleware.csrf import ( - CSRF_SESSION_KEY, CSRF_TOKEN_LENGTH, REASON_BAD_TOKEN, + CSRF_SESSION_KEY, CSRF_TOKEN_LENGTH, REASON_BAD_ORIGIN, REASON_BAD_TOKEN, REASON_NO_CSRF_COOKIE, CsrfViewMiddleware, _compare_masked_tokens as equivalent_tokens, get_token, ) @@ -510,6 +510,154 @@ class CsrfViewMiddlewareTestMixin: self.assertEqual(resp.status_code, 403) self.assertEqual(cm.records[0].getMessage(), 'Forbidden (%s): ' % REASON_BAD_TOKEN) + @override_settings(ALLOWED_HOSTS=['www.example.com']) + def test_bad_origin_bad_domain(self): + """A request with a bad origin is rejected.""" + req = self._get_POST_request_with_token() + req.META['HTTP_HOST'] = 'www.example.com' + req.META['HTTP_ORIGIN'] = 'https://www.evil.org' + mw = CsrfViewMiddleware(post_form_view) + self.assertIs(mw._origin_verified(req), False) + with self.assertLogs('django.security.csrf', 'WARNING') as cm: + response = mw.process_view(req, post_form_view, (), {}) + self.assertEqual(response.status_code, 403) + msg = REASON_BAD_ORIGIN % req.META['HTTP_ORIGIN'] + self.assertEqual(cm.records[0].getMessage(), 'Forbidden (%s): ' % msg) + + @override_settings(ALLOWED_HOSTS=['www.example.com']) + def test_bad_origin_null_origin(self): + """A request with a null origin is rejected.""" + req = self._get_POST_request_with_token() + req.META['HTTP_HOST'] = 'www.example.com' + req.META['HTTP_ORIGIN'] = 'null' + mw = CsrfViewMiddleware(post_form_view) + self.assertIs(mw._origin_verified(req), False) + with self.assertLogs('django.security.csrf', 'WARNING') as cm: + response = mw.process_view(req, post_form_view, (), {}) + self.assertEqual(response.status_code, 403) + msg = REASON_BAD_ORIGIN % req.META['HTTP_ORIGIN'] + self.assertEqual(cm.records[0].getMessage(), 'Forbidden (%s): ' % msg) + + @override_settings(ALLOWED_HOSTS=['www.example.com']) + def test_bad_origin_bad_protocol(self): + """A request with an origin with wrong protocol is rejected.""" + req = self._get_POST_request_with_token() + req._is_secure_override = True + req.META['HTTP_HOST'] = 'www.example.com' + req.META['HTTP_ORIGIN'] = 'http://example.com' + mw = CsrfViewMiddleware(post_form_view) + self.assertIs(mw._origin_verified(req), False) + with self.assertLogs('django.security.csrf', 'WARNING') as cm: + response = mw.process_view(req, post_form_view, (), {}) + self.assertEqual(response.status_code, 403) + msg = REASON_BAD_ORIGIN % req.META['HTTP_ORIGIN'] + self.assertEqual(cm.records[0].getMessage(), 'Forbidden (%s): ' % msg) + + @override_settings( + ALLOWED_HOSTS=['www.example.com'], + CSRF_TRUSTED_ORIGINS=[ + 'http://no-match.com', + 'https://*.example.com', + 'http://*.no-match.com', + 'http://*.no-match-2.com', + ], + ) + def test_bad_origin_csrf_trusted_origin_bad_protocol(self): + """ + A request with an origin with the wrong protocol compared to + CSRF_TRUSTED_ORIGINS is rejected. + """ + req = self._get_POST_request_with_token() + req._is_secure_override = True + req.META['HTTP_HOST'] = 'www.example.com' + req.META['HTTP_ORIGIN'] = 'http://foo.example.com' + mw = CsrfViewMiddleware(post_form_view) + self.assertIs(mw._origin_verified(req), False) + with self.assertLogs('django.security.csrf', 'WARNING') as cm: + response = mw.process_view(req, post_form_view, (), {}) + self.assertEqual(response.status_code, 403) + msg = REASON_BAD_ORIGIN % req.META['HTTP_ORIGIN'] + self.assertEqual(cm.records[0].getMessage(), 'Forbidden (%s): ' % msg) + self.assertEqual(mw.allowed_origins_exact, {'http://no-match.com'}) + self.assertEqual(mw.allowed_origin_subdomains, { + 'https': ['.example.com'], + 'http': ['.no-match.com', '.no-match-2.com'], + }) + + @override_settings(ALLOWED_HOSTS=['www.example.com']) + def test_bad_origin_cannot_be_parsed(self): + """ + A POST request with an origin that can't be parsed by urlparse() is + rejected. + """ + req = self._get_POST_request_with_token() + req.META['HTTP_HOST'] = 'www.example.com' + req.META['HTTP_ORIGIN'] = 'https://[' + mw = CsrfViewMiddleware(post_form_view) + self.assertIs(mw._origin_verified(req), False) + with self.assertLogs('django.security.csrf', 'WARNING') as cm: + response = mw.process_view(req, post_form_view, (), {}) + self.assertEqual(response.status_code, 403) + msg = REASON_BAD_ORIGIN % req.META['HTTP_ORIGIN'] + self.assertEqual(cm.records[0].getMessage(), 'Forbidden (%s): ' % msg) + + @override_settings(ALLOWED_HOSTS=['www.example.com']) + def test_good_origin_insecure(self): + """A POST HTTP request with a good origin is accepted.""" + req = self._get_POST_request_with_token() + req.META['HTTP_HOST'] = 'www.example.com' + req.META['HTTP_ORIGIN'] = 'http://www.example.com' + mw = CsrfViewMiddleware(post_form_view) + self.assertIs(mw._origin_verified(req), True) + response = mw.process_view(req, post_form_view, (), {}) + self.assertIsNone(response) + + @override_settings(ALLOWED_HOSTS=['www.example.com']) + def test_good_origin_secure(self): + """A POST HTTPS request with a good origin is accepted.""" + req = self._get_POST_request_with_token() + req._is_secure_override = True + req.META['HTTP_HOST'] = 'www.example.com' + req.META['HTTP_ORIGIN'] = 'https://www.example.com' + mw = CsrfViewMiddleware(post_form_view) + self.assertIs(mw._origin_verified(req), True) + response = mw.process_view(req, post_form_view, (), {}) + self.assertIsNone(response) + + @override_settings(ALLOWED_HOSTS=['www.example.com'], CSRF_TRUSTED_ORIGINS=['https://dashboard.example.com']) + def test_good_origin_csrf_trusted_origin_allowed(self): + """ + A POST request with an origin added to the CSRF_TRUSTED_ORIGINS + setting is accepted. + """ + req = self._get_POST_request_with_token() + req._is_secure_override = True + req.META['HTTP_HOST'] = 'www.example.com' + req.META['HTTP_ORIGIN'] = 'https://dashboard.example.com' + mw = CsrfViewMiddleware(post_form_view) + self.assertIs(mw._origin_verified(req), True) + resp = mw.process_view(req, post_form_view, (), {}) + self.assertIsNone(resp) + self.assertEqual(mw.allowed_origins_exact, {'https://dashboard.example.com'}) + self.assertEqual(mw.allowed_origin_subdomains, {}) + + @override_settings(ALLOWED_HOSTS=['www.example.com'], CSRF_TRUSTED_ORIGINS=['https://*.example.com']) + def test_good_origin_wildcard_csrf_trusted_origin_allowed(self): + """ + A POST request with an origin that matches a CSRF_TRUSTED_ORIGINS + wildcard is accepted. + """ + req = self._get_POST_request_with_token() + req._is_secure_override = True + req.META['HTTP_HOST'] = 'www.example.com' + req.META['HTTP_ORIGIN'] = 'https://foo.example.com' + mw = CsrfViewMiddleware(post_form_view) + self.assertIs(mw._origin_verified(req), True) + response = mw.process_view(req, post_form_view, (), {}) + self.assertIsNone(response) + self.assertEqual(mw.allowed_origins_exact, set()) + self.assertEqual(mw.allowed_origin_subdomains, {'https': ['.example.com']}) + class CsrfViewMiddlewareTests(CsrfViewMiddlewareTestMixin, SimpleTestCase):