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:
Matt Robenolt 2015-03-17 02:52:55 -07:00 committed by Tim Graham
parent 535809e121
commit b0c56b895f
8 changed files with 177 additions and 64 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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