Fixed #24496 -- Added CSRF Referer checking against CSRF_COOKIE_DOMAIN.
Thanks Seth Gottlieb for help with the documentation and Carl Meyer and Joshua Kehn for reviews.
This commit is contained in:
parent
535809e121
commit
b0c56b895f
|
@ -16,6 +16,7 @@ from django.utils.datastructures import ImmutableList, MultiValueDict
|
|||
from django.utils.encoding import (
|
||||
escape_uri_path, force_bytes, force_str, force_text, iri_to_uri,
|
||||
)
|
||||
from django.utils.http import is_same_domain
|
||||
from django.utils.six.moves.urllib.parse import (
|
||||
parse_qsl, quote, urlencode, urljoin, urlsplit,
|
||||
)
|
||||
|
@ -546,15 +547,7 @@ def validate_host(host, allowed_hosts):
|
|||
host = host[:-1] if host.endswith('.') else host
|
||||
|
||||
for pattern in allowed_hosts:
|
||||
pattern = pattern.lower()
|
||||
match = (
|
||||
pattern == '*' or
|
||||
pattern.startswith('.') and (
|
||||
host.endswith(pattern) or host == pattern[1:]
|
||||
) or
|
||||
pattern == host
|
||||
)
|
||||
if match:
|
||||
if pattern == '*' or is_same_domain(host, pattern):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
|
|
@ -14,7 +14,8 @@ from django.core.urlresolvers import get_callable
|
|||
from django.utils.cache import patch_vary_headers
|
||||
from django.utils.crypto import constant_time_compare, get_random_string
|
||||
from django.utils.encoding import force_text
|
||||
from django.utils.http import same_origin
|
||||
from django.utils.http import is_same_domain
|
||||
from django.utils.six.moves.urllib.parse import urlparse
|
||||
|
||||
logger = logging.getLogger('django.request')
|
||||
|
||||
|
@ -22,6 +23,8 @@ 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."
|
||||
REASON_BAD_TOKEN = "CSRF token missing or incorrect."
|
||||
REASON_MALFORMED_REFERER = "Referer checking failed - Referer is malformed."
|
||||
REASON_INSECURE_REFERER = "Referer checking failed - Referer is insecure while host is secure."
|
||||
|
||||
CSRF_KEY_LENGTH = 32
|
||||
|
||||
|
@ -154,15 +157,35 @@ class CsrfViewMiddleware(object):
|
|||
if referer is None:
|
||||
return self._reject(request, REASON_NO_REFERER)
|
||||
|
||||
referer = urlparse(referer)
|
||||
|
||||
# Make sure we have a valid URL for Referer.
|
||||
if '' in (referer.scheme, referer.netloc):
|
||||
return self._reject(request, REASON_MALFORMED_REFERER)
|
||||
|
||||
# Ensure that our Referer is also secure.
|
||||
if referer.scheme != 'https':
|
||||
return self._reject(request, REASON_INSECURE_REFERER)
|
||||
|
||||
# If there isn't a CSRF_COOKIE_DOMAIN, assume we need an exact
|
||||
# match on host:port. If not, obey the cookie rules.
|
||||
if settings.CSRF_COOKIE_DOMAIN is None:
|
||||
# request.get_host() includes the port.
|
||||
good_referer = request.get_host()
|
||||
else:
|
||||
good_referer = settings.CSRF_COOKIE_DOMAIN
|
||||
server_port = request.META['SERVER_PORT']
|
||||
if server_port not in ('443', '80'):
|
||||
good_referer = '%s:%s' % (good_referer, server_port)
|
||||
|
||||
# Here we generate a list of all acceptable HTTP referers,
|
||||
# including the current host since that has been validated
|
||||
# upstream.
|
||||
good_hosts = list(settings.CSRF_TRUSTED_ORIGINS)
|
||||
# Note that request.get_host() includes the port.
|
||||
good_hosts.append(request.get_host())
|
||||
good_referers = ['https://{0}/'.format(host) for host in good_hosts]
|
||||
if not any(same_origin(referer, host) for host in good_referers):
|
||||
reason = REASON_BAD_REFERER % referer
|
||||
good_hosts.append(good_referer)
|
||||
|
||||
if not any(is_same_domain(referer.netloc, host) for host in good_hosts):
|
||||
reason = REASON_BAD_REFERER % referer.geturl()
|
||||
return self._reject(request, reason)
|
||||
|
||||
if csrf_token is None:
|
||||
|
|
|
@ -253,18 +253,24 @@ def quote_etag(etag):
|
|||
return '"%s"' % etag.replace('\\', '\\\\').replace('"', '\\"')
|
||||
|
||||
|
||||
def same_origin(url1, url2):
|
||||
def is_same_domain(host, pattern):
|
||||
"""
|
||||
Checks if two URLs are 'same-origin'
|
||||
Return ``True`` if the host is either an exact match or a match
|
||||
to the wildcard pattern.
|
||||
|
||||
Any pattern beginning with a period matches a domain and all of its
|
||||
subdomains. (e.g. ``.example.com`` matches ``example.com`` and
|
||||
``foo.example.com``). Anything else is an exact string match.
|
||||
"""
|
||||
p1, p2 = urlparse(url1), urlparse(url2)
|
||||
try:
|
||||
o1 = (p1.scheme, p1.hostname, p1.port or PROTOCOL_TO_PORT[p1.scheme])
|
||||
o2 = (p2.scheme, p2.hostname, p2.port or PROTOCOL_TO_PORT[p2.scheme])
|
||||
return o1 == o2
|
||||
except (ValueError, KeyError):
|
||||
if not pattern:
|
||||
return False
|
||||
|
||||
pattern = pattern.lower()
|
||||
return (
|
||||
pattern[0] == '.' and (host.endswith(pattern) or host == pattern[1:]) or
|
||||
pattern == host
|
||||
)
|
||||
|
||||
|
||||
def is_safe_url(url, host=None):
|
||||
"""
|
||||
|
|
|
@ -257,11 +257,19 @@ The CSRF protection is based on the following things:
|
|||
due to the fact that HTTP 'Set-Cookie' headers are (unfortunately) accepted
|
||||
by clients that are talking to a site under HTTPS. (Referer checking is not
|
||||
done for HTTP requests because the presence of the Referer header is not
|
||||
reliable enough under HTTP.) Expanding the accepted referers beyond the
|
||||
current host can be done with the :setting:`CSRF_TRUSTED_ORIGINS` setting.
|
||||
reliable enough under HTTP.)
|
||||
|
||||
This ensures that only forms that have originated from your Web site can be used
|
||||
to POST data back.
|
||||
If the :setting:`CSRF_COOKIE_DOMAIN` setting is set, the referer is compared
|
||||
against it. This setting supports subdomains. For example,
|
||||
``CSRF_COOKIE_DOMAIN = '.example.com'`` will allow POST requests from
|
||||
``www.example.com`` and ``api.example.com``. If the setting is not set, then
|
||||
the referer must match the HTTP ``Host`` header.
|
||||
|
||||
Expanding the accepted referers beyond the current host or cookie domain can
|
||||
be done with the :setting:`CSRF_TRUSTED_ORIGINS` setting.
|
||||
|
||||
This ensures that only forms that have originated from trusted domains can be
|
||||
used to POST data back.
|
||||
|
||||
It deliberately ignores GET requests (and other requests that are defined as
|
||||
'safe' by :rfc:`2616`). These requests ought never to have any potentially
|
||||
|
@ -269,6 +277,10 @@ dangerous side effects , and so a CSRF attack with a GET request ought to be
|
|||
harmless. :rfc:`2616` defines POST, PUT and DELETE as 'unsafe', and all other
|
||||
methods are assumed to be unsafe, for maximum protection.
|
||||
|
||||
.. versionchanged:: 1.9
|
||||
|
||||
Checking against the :setting:`CSRF_COOKIE_DOMAIN` setting was added.
|
||||
|
||||
Caching
|
||||
=======
|
||||
|
||||
|
|
|
@ -444,6 +444,8 @@ 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 ``"subdomain.example.com"`` to this list.
|
||||
The setting also supports subdomains, so you could add ``".example.com"``, for
|
||||
example, to allow access from all subdomains of ``example.com``.
|
||||
|
||||
.. setting:: DATABASES
|
||||
|
||||
|
|
|
@ -516,6 +516,10 @@ CSRF
|
|||
* The request header's name used for CSRF authentication can be customized
|
||||
with :setting:`CSRF_HEADER_NAME`.
|
||||
|
||||
* The CSRF referer header is now validated against the
|
||||
:setting:`CSRF_COOKIE_DOMAIN` setting if set. See :ref:`how-csrf-works` for
|
||||
details.
|
||||
|
||||
* The new :setting:`CSRF_TRUSTED_ORIGINS` setting provides a way to allow
|
||||
cross-origin unsafe requests (e.g. ``POST``) over HTTPS.
|
||||
|
||||
|
|
|
@ -295,7 +295,7 @@ class CsrfViewMiddlewareTest(SimpleTestCase):
|
|||
csrf_cookie = resp2.cookies[settings.CSRF_COOKIE_NAME]
|
||||
self._check_token_present(resp, csrf_id=csrf_cookie.value)
|
||||
|
||||
@override_settings(ALLOWED_HOSTS=['www.example.com'])
|
||||
@override_settings(DEBUG=True)
|
||||
def test_https_bad_referer(self):
|
||||
"""
|
||||
Test that a POST HTTPS request with a bad referer is rejected
|
||||
|
@ -304,27 +304,50 @@ class CsrfViewMiddlewareTest(SimpleTestCase):
|
|||
req._is_secure_override = True
|
||||
req.META['HTTP_HOST'] = 'www.example.com'
|
||||
req.META['HTTP_REFERER'] = 'https://www.evil.org/somepage'
|
||||
req2 = CsrfViewMiddleware().process_view(req, post_form_view, (), {})
|
||||
self.assertIsNotNone(req2)
|
||||
self.assertEqual(403, req2.status_code)
|
||||
req.META['SERVER_PORT'] = '443'
|
||||
response = CsrfViewMiddleware().process_view(req, post_form_view, (), {})
|
||||
self.assertContains(
|
||||
response,
|
||||
'Referer checking failed - https://www.evil.org/somepage does not '
|
||||
'match any trusted origins.',
|
||||
status_code=403,
|
||||
)
|
||||
|
||||
@override_settings(ALLOWED_HOSTS=['www.example.com'])
|
||||
@override_settings(DEBUG=True)
|
||||
def test_https_malformed_referer(self):
|
||||
"""
|
||||
A POST HTTPS request with a bad referer is rejected.
|
||||
"""
|
||||
malformed_referer_msg = 'Referer checking failed - Referer is malformed.'
|
||||
req = self._get_POST_request_with_token()
|
||||
req._is_secure_override = True
|
||||
req.META['HTTP_HOST'] = 'www.example.com'
|
||||
req.META['HTTP_REFERER'] = 'http://http://www.example.com/'
|
||||
req2 = CsrfViewMiddleware().process_view(req, post_form_view, (), {})
|
||||
self.assertIsNotNone(req2)
|
||||
self.assertEqual(403, req2.status_code)
|
||||
response = CsrfViewMiddleware().process_view(req, post_form_view, (), {})
|
||||
self.assertContains(
|
||||
response,
|
||||
'Referer checking failed - Referer is insecure while host is secure.',
|
||||
status_code=403,
|
||||
)
|
||||
# Empty
|
||||
req.META['HTTP_REFERER'] = ''
|
||||
response = CsrfViewMiddleware().process_view(req, post_form_view, (), {})
|
||||
self.assertContains(response, malformed_referer_msg, status_code=403)
|
||||
# Non-ASCII
|
||||
req.META['HTTP_REFERER'] = b'\xd8B\xf6I\xdf'
|
||||
req2 = CsrfViewMiddleware().process_view(req, post_form_view, (), {})
|
||||
self.assertIsNotNone(req2)
|
||||
self.assertEqual(403, req2.status_code)
|
||||
response = CsrfViewMiddleware().process_view(req, post_form_view, (), {})
|
||||
self.assertContains(response, malformed_referer_msg, status_code=403)
|
||||
# missing scheme
|
||||
# >>> urlparse('//example.com/')
|
||||
# ParseResult(scheme='', netloc='example.com', path='/', params='', query='', fragment='')
|
||||
req.META['HTTP_REFERER'] = '//example.com/'
|
||||
response = CsrfViewMiddleware().process_view(req, post_form_view, (), {})
|
||||
self.assertContains(response, malformed_referer_msg, status_code=403)
|
||||
# missing netloc
|
||||
# >>> urlparse('https://')
|
||||
# ParseResult(scheme='https', netloc='', path='', params='', query='', fragment='')
|
||||
req.META['HTTP_REFERER'] = 'https://'
|
||||
response = CsrfViewMiddleware().process_view(req, post_form_view, (), {})
|
||||
self.assertContains(response, malformed_referer_msg, status_code=403)
|
||||
|
||||
@override_settings(ALLOWED_HOSTS=['www.example.com'])
|
||||
def test_https_good_referer(self):
|
||||
|
@ -365,6 +388,62 @@ class CsrfViewMiddlewareTest(SimpleTestCase):
|
|||
req2 = CsrfViewMiddleware().process_view(req, post_form_view, (), {})
|
||||
self.assertIsNone(req2)
|
||||
|
||||
@override_settings(ALLOWED_HOSTS=['www.example.com'], CSRF_TRUSTED_ORIGINS=['.example.com'])
|
||||
def test_https_csrf_wildcard_trusted_origin_allowed(self):
|
||||
"""
|
||||
A POST HTTPS request with a referer that matches a CSRF_TRUSTED_ORIGINS
|
||||
wilcard is accepted.
|
||||
"""
|
||||
req = self._get_POST_request_with_token()
|
||||
req._is_secure_override = True
|
||||
req.META['HTTP_HOST'] = 'www.example.com'
|
||||
req.META['HTTP_REFERER'] = 'https://dashboard.example.com'
|
||||
response = CsrfViewMiddleware().process_view(req, post_form_view, (), {})
|
||||
self.assertIsNone(response)
|
||||
|
||||
@override_settings(ALLOWED_HOSTS=['www.example.com'], CSRF_COOKIE_DOMAIN='.example.com')
|
||||
def test_https_good_referer_matches_cookie_domain(self):
|
||||
"""
|
||||
A POST HTTPS request with a good referer should be accepted from a
|
||||
subdomain that's allowed by CSRF_COOKIE_DOMAIN.
|
||||
"""
|
||||
req = self._get_POST_request_with_token()
|
||||
req._is_secure_override = True
|
||||
req.META['HTTP_REFERER'] = 'https://foo.example.com/'
|
||||
req.META['SERVER_PORT'] = '443'
|
||||
response = CsrfViewMiddleware().process_view(req, post_form_view, (), {})
|
||||
self.assertIsNone(response)
|
||||
|
||||
@override_settings(ALLOWED_HOSTS=['www.example.com'], CSRF_COOKIE_DOMAIN='.example.com')
|
||||
def test_https_good_referer_matches_cookie_domain_with_different_port(self):
|
||||
"""
|
||||
A POST HTTPS request with a good referer should be accepted from a
|
||||
subdomain that's allowed by CSRF_COOKIE_DOMAIN and a non-443 port.
|
||||
"""
|
||||
req = self._get_POST_request_with_token()
|
||||
req._is_secure_override = True
|
||||
req.META['HTTP_HOST'] = 'www.example.com'
|
||||
req.META['HTTP_REFERER'] = 'https://foo.example.com:4443/'
|
||||
req.META['SERVER_PORT'] = '4443'
|
||||
response = CsrfViewMiddleware().process_view(req, post_form_view, (), {})
|
||||
self.assertIsNone(response)
|
||||
|
||||
@override_settings(CSRF_COOKIE_DOMAIN='.example.com', DEBUG=True)
|
||||
def test_https_reject_insecure_referer(self):
|
||||
"""
|
||||
A POST HTTPS request from an insecure referer should be rejected.
|
||||
"""
|
||||
req = self._get_POST_request_with_token()
|
||||
req._is_secure_override = True
|
||||
req.META['HTTP_REFERER'] = 'http://example.com/'
|
||||
req.META['SERVER_PORT'] = '443'
|
||||
response = CsrfViewMiddleware().process_view(req, post_form_view, (), {})
|
||||
self.assertContains(
|
||||
response,
|
||||
'Referer checking failed - Referer is insecure while host is secure.',
|
||||
status_code=403,
|
||||
)
|
||||
|
||||
def test_ensures_csrf_cookie_no_middleware(self):
|
||||
"""
|
||||
The ensure_csrf_cookie() decorator works without middleware.
|
||||
|
|
|
@ -10,31 +10,6 @@ from django.utils.datastructures import MultiValueDict
|
|||
|
||||
class TestUtilsHttp(unittest.TestCase):
|
||||
|
||||
def test_same_origin_true(self):
|
||||
# Identical
|
||||
self.assertTrue(http.same_origin('http://foo.com/', 'http://foo.com/'))
|
||||
# One with trailing slash - see #15617
|
||||
self.assertTrue(http.same_origin('http://foo.com', 'http://foo.com/'))
|
||||
self.assertTrue(http.same_origin('http://foo.com/', 'http://foo.com'))
|
||||
# With port
|
||||
self.assertTrue(http.same_origin('https://foo.com:8000', 'https://foo.com:8000/'))
|
||||
# No port given but according to RFC6454 still the same origin
|
||||
self.assertTrue(http.same_origin('http://foo.com', 'http://foo.com:80/'))
|
||||
self.assertTrue(http.same_origin('https://foo.com', 'https://foo.com:443/'))
|
||||
|
||||
def test_same_origin_false(self):
|
||||
# Different scheme
|
||||
self.assertFalse(http.same_origin('http://foo.com', 'https://foo.com'))
|
||||
# Different host
|
||||
self.assertFalse(http.same_origin('http://foo.com', 'http://goo.com'))
|
||||
# Different host again
|
||||
self.assertFalse(http.same_origin('http://foo.com', 'http://foo.com.evil.com'))
|
||||
# Different port
|
||||
self.assertFalse(http.same_origin('http://foo.com:8000', 'http://foo.com:8001'))
|
||||
# No port given
|
||||
self.assertFalse(http.same_origin('http://foo.com', 'http://foo.com:8000/'))
|
||||
self.assertFalse(http.same_origin('https://foo.com', 'https://foo.com:8000/'))
|
||||
|
||||
def test_urlencode(self):
|
||||
# 2-tuples (the norm)
|
||||
result = http.urlencode((('a', 1), ('b', 2), ('c', 3)))
|
||||
|
@ -157,6 +132,25 @@ class TestUtilsHttp(unittest.TestCase):
|
|||
http.urlunquote_plus('Paris+&+Orl%C3%A9ans'),
|
||||
'Paris & Orl\xe9ans')
|
||||
|
||||
def test_is_same_domain_good(self):
|
||||
for pair in (
|
||||
('example.com', 'example.com'),
|
||||
('example.com', '.example.com'),
|
||||
('foo.example.com', '.example.com'),
|
||||
('example.com:8888', 'example.com:8888'),
|
||||
('example.com:8888', '.example.com:8888'),
|
||||
('foo.example.com:8888', '.example.com:8888'),
|
||||
):
|
||||
self.assertTrue(http.is_same_domain(*pair))
|
||||
|
||||
def test_is_same_domain_bad(self):
|
||||
for pair in (
|
||||
('example2.com', 'example.com'),
|
||||
('foo.example.com', 'example.com'),
|
||||
('example.com:9999', 'example.com:8888'),
|
||||
):
|
||||
self.assertFalse(http.is_same_domain(*pair))
|
||||
|
||||
|
||||
class ETagProcessingTests(unittest.TestCase):
|
||||
def test_parsing(self):
|
||||
|
|
Loading…
Reference in New Issue