Browse Source

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.
Tim Graham 4 years ago
parent
commit
2411b8b5eb
5 changed files with 238 additions and 13 deletions
  1. 50 1
      django/middleware/csrf.py
  2. 14 4
      docs/ref/csrf.txt
  3. 13 6
      docs/ref/settings.txt
  4. 12 1
      docs/releases/4.0.txt
  5. 149 1
      tests/csrf_tests/tests.py

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

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

+ 13 - 6
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 <django.http.HttpRequest.is_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``

+ 12 - 1
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 <csrf-trusted-origins-changes-4.0>` 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
 -------------
 

+ 149 - 1
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):