summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--django/http/request.py11
-rw-r--r--django/middleware/csrf.py35
-rw-r--r--django/utils/http.py22
-rw-r--r--docs/ref/csrf.txt20
-rw-r--r--docs/ref/settings.txt2
-rw-r--r--docs/releases/1.9.txt4
-rw-r--r--tests/csrf_tests/tests.py105
-rw-r--r--tests/utils_tests/test_http.py44
8 files changed, 178 insertions, 65 deletions
diff --git a/django/http/request.py b/django/http/request.py
index 15f1c4614e..22405d8306 100644
--- a/django/http/request.py
+++ b/django/http/request.py
@@ -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
diff --git a/django/middleware/csrf.py b/django/middleware/csrf.py
index dee5bb1d93..797b6f3eee 100644
--- a/django/middleware/csrf.py
+++ b/django/middleware/csrf.py
@@ -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:
diff --git a/django/utils/http.py b/django/utils/http.py
index 34c17424f6..8bbafaedec 100644
--- a/django/utils/http.py
+++ b/django/utils/http.py
@@ -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):
"""
diff --git a/docs/ref/csrf.txt b/docs/ref/csrf.txt
index ba24339a78..77b176455c 100644
--- a/docs/ref/csrf.txt
+++ b/docs/ref/csrf.txt
@@ -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
=======
diff --git a/docs/ref/settings.txt b/docs/ref/settings.txt
index 2c855a0e1e..6a398e1c60 100644
--- a/docs/ref/settings.txt
+++ b/docs/ref/settings.txt
@@ -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
diff --git a/docs/releases/1.9.txt b/docs/releases/1.9.txt
index d5ed37737f..efece97853 100644
--- a/docs/releases/1.9.txt
+++ b/docs/releases/1.9.txt
@@ -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.
diff --git a/tests/csrf_tests/tests.py b/tests/csrf_tests/tests.py
index 382242d6a4..6c6f49d2b8 100644
--- a/tests/csrf_tests/tests.py
+++ b/tests/csrf_tests/tests.py
@@ -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)
-
- @override_settings(ALLOWED_HOSTS=['www.example.com'])
+ 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(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.
diff --git a/tests/utils_tests/test_http.py b/tests/utils_tests/test_http.py
index 74c6905294..baa126d423 100644
--- a/tests/utils_tests/test_http.py
+++ b/tests/utils_tests/test_http.py
@@ -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):