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:
parent
dba44a7a7a
commit
2411b8b5eb
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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``
|
||||||
|
@ -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
|
||||||
-------------
|
-------------
|
||||||
|
|
||||||
|
@ -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):
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user