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 logging
 import re
 import re
 import string
 import string
+from collections import defaultdict
 from urllib.parse import urlparse
 from urllib.parse import urlparse
 
 
 from django.conf import settings
 from django.conf import settings
@@ -21,6 +22,7 @@ from django.utils.log import log_response
 
 
 logger = logging.getLogger('django.security.csrf')
 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_NO_REFERER = "Referer checking failed - no Referer."
 REASON_BAD_REFERER = "Referer checking failed - %s does not match any trusted origins."
 REASON_BAD_REFERER = "Referer checking failed - %s does not match any trusted origins."
 REASON_NO_CSRF_COOKIE = "CSRF cookie not set."
 REASON_NO_CSRF_COOKIE = "CSRF cookie not set."
@@ -144,6 +146,24 @@ class CsrfViewMiddleware(MiddlewareMixin):
             for origin in settings.CSRF_TRUSTED_ORIGINS
             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
     # The _accept and _reject methods currently only exist for the sake of the
     # requires_csrf_token decorator.
     # requires_csrf_token decorator.
     def _accept(self, request):
     def _accept(self, request):
@@ -204,6 +224,27 @@ class CsrfViewMiddleware(MiddlewareMixin):
             # Set the Vary header since content varies with the CSRF cookie.
             # Set the Vary header since content varies with the CSRF cookie.
             patch_vary_headers(response, ('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):
     def process_request(self, request):
         csrf_token = self._get_token(request)
         csrf_token = self._get_token(request)
         if csrf_token is not None:
         if csrf_token is not None:
@@ -229,7 +270,15 @@ class CsrfViewMiddleware(MiddlewareMixin):
                 # branches that call reject().
                 # branches that call reject().
                 return self._accept(request)
                 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/
                 # Suppose user visits http://example.com/
                 # An active network attacker (man-in-the-middle, MITM) sends a
                 # An active network attacker (man-in-the-middle, MITM) sends a
                 # POST form that targets https://example.com/detonate-bomb/ and
                 # 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``.
    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
    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
    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
    Expanding the accepted referers beyond the current host or cookie domain can
    be done with the :setting:`CSRF_TRUSTED_ORIGINS` setting.
    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
 This ensures that only forms that have originated from trusted domains can be
 used to POST data back.
 used to POST data back.
 
 
@@ -314,6 +323,7 @@ vulnerability allows and much worse).
     sites.
     sites.
 
 
 .. _BREACH: http://breachattack.com/
 .. _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
 .. _disable the referer: https://www.w3.org/TR/referrer-policy/#referrer-policy-delivery
 
 
 Caching
 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``).
 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
 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
 The setting also supports subdomains, so you could add
 ``'https://*.example.com'``, for example, to allow access from all subdomains
 ``'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
     The values in older versions must only include the hostname (possibly with
     a leading dot) and not the scheme or an asterisk.
     a leading dot) and not the scheme or an asterisk.
 
 
+    Also, ``Origin`` header checking isn't performed in older versions.
+
 .. setting:: DATABASES
 .. setting:: DATABASES
 
 
 ``DATABASES``
 ``DATABASES``

+ 12 - 1
docs/releases/4.0.txt

@@ -149,7 +149,9 @@ Cache
 CSRF
 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
 Decorators
 ~~~~~~~~~~
 ~~~~~~~~~~
@@ -323,6 +325,15 @@ the dot. For example, change ``'.example.com'`` to ``'https://*.example.com'``.
 
 
 A system check detects any required changes.
 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
 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.core.exceptions import ImproperlyConfigured
 from django.http import HttpRequest, HttpResponse
 from django.http import HttpRequest, HttpResponse
 from django.middleware.csrf import (
 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,
     REASON_NO_CSRF_COOKIE, CsrfViewMiddleware,
     _compare_masked_tokens as equivalent_tokens, get_token,
     _compare_masked_tokens as equivalent_tokens, get_token,
 )
 )
@@ -510,6 +510,154 @@ class CsrfViewMiddlewareTestMixin:
         self.assertEqual(resp.status_code, 403)
         self.assertEqual(resp.status_code, 403)
         self.assertEqual(cm.records[0].getMessage(), 'Forbidden (%s): ' % REASON_BAD_TOKEN)
         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):
 class CsrfViewMiddlewareTests(CsrfViewMiddlewareTestMixin, SimpleTestCase):