1
0
mirror of https://github.com/django/django.git synced 2025-05-29 18:26:29 +00:00

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.
This commit is contained in:
Tim Graham 2021-01-02 18:46:17 -05:00 committed by Mariusz Felisiak
parent dba44a7a7a
commit 2411b8b5eb
5 changed files with 238 additions and 13 deletions

View File

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

View File

@ -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`` verifies the `Origin header`_, if provided by the
``CsrfViewMiddleware``. This means that even if a subdomain can set or browser, against the current host and the :setting:`CSRF_TRUSTED_ORIGINS`
modify cookies on your domain, it can't force a user to post to your setting. This provides protection against cross-subdomain attacks.
application since that request won't come from your own exact domain.
#. 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

View File

@ -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`` request that doesn't include the ``Origin`` header, the request must have a
header that matches the origin present in the ``Host`` header. This prevents, ``Referer`` header that matches the origin present in the ``Host`` header.
for example, a ``POST`` request from ``subdomain.example.com`` from succeeding
against ``api.example.com``. If you need cross-origin unsafe requests over These checks prevent, for example, a ``POST`` request from
HTTPS, continuing the example, add ``'https://subdomain.example.com'`` to this ``subdomain.example.com`` from succeeding against ``api.example.com``. If you
list (and/or ``http://...`` if requests originate from an insecure page). 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``

View File

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

View File

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