Jelajahi Sumber

Added a new required ALLOWED_HOSTS setting for HTTP host header validation.

This is a security fix; disclosure and advisory coming shortly.
Carl Meyer 12 tahun lalu
induk
melakukan
d51fb74360

+ 4 - 0
django/conf/global_settings.py

@@ -29,6 +29,10 @@ ADMINS = ()
 #   * Receive x-headers
 INTERNAL_IPS = ()
 
+# Hosts/domain names that are valid for this site.
+# "*" matches anything, ".example.com" matches example.com and all subdomains
+ALLOWED_HOSTS = []
+
 # Local time zone for this installation. All choices can be found here:
 # http://en.wikipedia.org/wiki/List_of_tz_zones_by_name (although not all
 # systems may support all possibilities). When USE_TZ is True, this is

+ 4 - 0
django/conf/project_template/project_name/settings.py

@@ -25,6 +25,10 @@ DEBUG = True
 
 TEMPLATE_DEBUG = True
 
+# Hosts/domain names that are valid for this site; required if DEBUG is False
+# See https://docs.djangoproject.com/en/{{ docs_version }}/ref/settings/#allowed-hosts
+ALLOWED_HOSTS = []
+
 
 # Application definition
 

+ 1 - 0
django/contrib/auth/tests/views.py

@@ -108,6 +108,7 @@ class PasswordResetTest(AuthViewsTestCase):
         self.assertEqual(len(mail.outbox), 1)
         self.assertEqual("staffmember@example.com", mail.outbox[0].from_email)
 
+    @override_settings(ALLOWED_HOSTS=['adminsite.com'])
     def test_admin_reset(self):
         "If the reset view is marked as being for admin, the HTTP_HOST header is used for a domain override."
         response = self.client.post('/admin_password_reset/',

+ 2 - 0
django/contrib/contenttypes/tests.py

@@ -6,6 +6,7 @@ from django.contrib.contenttypes.views import shortcut
 from django.contrib.sites.models import Site, get_current_site
 from django.http import HttpRequest, Http404
 from django.test import TestCase
+from django.test.utils import override_settings
 from django.utils.http import urlquote
 from django.utils import six
 from django.utils.encoding import python_2_unicode_compatible
@@ -203,6 +204,7 @@ class ContentTypesTests(TestCase):
         })
 
 
+    @override_settings(ALLOWED_HOSTS=['example.com'])
     def test_shortcut_view(self):
         """
         Check that the shortcut view (used for the admin "view on site"

+ 2 - 0
django/contrib/sites/tests.py

@@ -5,6 +5,7 @@ from django.contrib.sites.models import Site, RequestSite, get_current_site
 from django.core.exceptions import ObjectDoesNotExist
 from django.http import HttpRequest
 from django.test import TestCase
+from django.test.utils import override_settings
 
 
 class SitesFrameworkTests(TestCase):
@@ -41,6 +42,7 @@ class SitesFrameworkTests(TestCase):
         site = Site.objects.get_current()
         self.assertEqual("Example site", site.name)
 
+    @override_settings(ALLOWED_HOSTS=['example.com'])
     def test_get_current_site(self):
         # Test that the correct Site object is returned
         request = HttpRequest()

+ 48 - 5
django/http/request.py

@@ -64,11 +64,12 @@ class HttpRequest(object):
             if server_port != ('443' if self.is_secure() else '80'):
                 host = '%s:%s' % (host, server_port)
 
-        # Disallow potentially poisoned hostnames.
-        if not host_validation_re.match(host.lower()):
-            raise SuspiciousOperation('Invalid HTTP_HOST header: %s' % host)
-
-        return host
+        allowed_hosts = ['*'] if settings.DEBUG else settings.ALLOWED_HOSTS
+        if validate_host(host, allowed_hosts):
+            return host
+        else:
+            raise SuspiciousOperation(
+                "Invalid HTTP_HOST header (you may need to set ALLOWED_HOSTS): %s" % host)
 
     def get_full_path(self):
         # RFC 3986 requires query string arguments to be in the ASCII range.
@@ -450,3 +451,45 @@ def bytes_to_text(s, encoding):
         return six.text_type(s, encoding, 'replace')
     else:
         return s
+
+
+def validate_host(host, allowed_hosts):
+    """
+    Validate the given host header value for this site.
+
+    Check that the host looks valid and matches a host or host pattern in the
+    given list of ``allowed_hosts``. Any pattern beginning with a period
+    matches a domain and all its subdomains (e.g. ``.example.com`` matches
+    ``example.com`` and any subdomain), ``*`` matches anything, and anything
+    else must match exactly.
+
+    Return ``True`` for a valid host, ``False`` otherwise.
+
+    """
+    # All validation is case-insensitive
+    host = host.lower()
+
+    # Basic sanity check
+    if not host_validation_re.match(host):
+        return False
+
+    # Validate only the domain part.
+    if host[-1] == ']':
+        # It's an IPv6 address without a port.
+        domain = host
+    else:
+        domain = host.rsplit(':', 1)[0]
+
+    for pattern in allowed_hosts:
+        pattern = pattern.lower()
+        match = (
+            pattern == '*' or
+            pattern.startswith('.') and (
+                domain.endswith(pattern) or domain == pattern[1:]
+                ) or
+            pattern == domain
+            )
+        if match:
+            return True
+
+    return False

+ 6 - 0
django/test/utils.py

@@ -78,6 +78,9 @@ def setup_test_environment():
     mail.original_email_backend = settings.EMAIL_BACKEND
     settings.EMAIL_BACKEND = 'django.core.mail.backends.locmem.EmailBackend'
 
+    settings._original_allowed_hosts = settings.ALLOWED_HOSTS
+    settings.ALLOWED_HOSTS = ['*']
+
     mail.outbox = []
 
     deactivate()
@@ -96,6 +99,9 @@ def teardown_test_environment():
     settings.EMAIL_BACKEND = mail.original_email_backend
     del mail.original_email_backend
 
+    settings.ALLOWED_HOSTS = settings._original_allowed_hosts
+    del settings._original_allowed_hosts
+
     del mail.outbox
 
 

+ 36 - 0
docs/ref/settings.txt

@@ -56,6 +56,42 @@ of (Full name, email address). Example::
 Note that Django will email *all* of these people whenever an error happens.
 See :doc:`/howto/error-reporting` for more information.
 
+.. setting:: ALLOWED_HOSTS
+
+ALLOWED_HOSTS
+-------------
+
+Default: ``[]`` (Empty list)
+
+A list of strings representing the host/domain names that this Django site can
+serve. This is a security measure to prevent an attacker from poisoning caches
+and password reset emails with links to malicious hosts by submitting requests
+with a fake HTTP ``Host`` header, which is possible even under many
+seemingly-safe webserver configurations.
+
+Values in this list can be fully qualified names (e.g. ``'www.example.com'``),
+in which case they will be matched against the request's ``Host`` header
+exactly (case-insensitive, not including port). A value beginning with a period
+can be used as a subdomain wildcard: ``'.example.com'`` will match
+``example.com``, ``www.example.com``, and any other subdomain of
+``example.com``. A value of ``'*'`` will match anything; in this case you are
+responsible to provide your own validation of the ``Host`` header (perhaps in a
+middleware; if so this middleware must be listed first in
+:setting:`MIDDLEWARE_CLASSES`).
+
+If the ``Host`` header (or ``X-Forwarded-Host`` if
+:setting:`USE_X_FORWARDED_HOST` is enabled) does not match any value in this
+list, the :meth:`django.http.HttpRequest.get_host()` method will raise
+:exc:`~django.core.exceptions.SuspiciousOperation`.
+
+When :setting:`DEBUG` is ``True`` or when running tests, host validation is
+disabled; any host will be accepted. Thus it's usually only necessary to set it
+in production.
+
+This validation only applies via :meth:`~django.http.HttpRequest.get_host()`;
+if your code accesses the ``Host`` header directly from ``request.META`` you
+are bypassing this security protection.
+
 .. setting:: ALLOWED_INCLUDE_ROOTS
 
 ALLOWED_INCLUDE_ROOTS

+ 10 - 0
docs/releases/1.5.txt

@@ -354,6 +354,16 @@ Backwards incompatible changes in 1.5
     deprecation timeline for a given feature, its removal may appear as a
     backwards incompatible change.
 
+``ALLOWED_HOSTS`` required in production
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+The new :setting:`ALLOWED_HOSTS` setting validates the request's ``Host``
+header and protects against host-poisoning attacks. This setting is now
+required whenever :setting:`DEBUG` is ``False``, or else
+:meth:`django.http.HttpRequest.get_host()` will raise
+:exc:`~django.core.exceptions.SuspiciousOperation`. For more details see the
+:setting:`full documentation<ALLOWED_HOSTS>` for the new setting.
+
 Managers on abstract models
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~
 

+ 30 - 37
docs/topics/security.txt

@@ -160,47 +160,40 @@ server, there are some additional steps you may need:
 
 .. _host-headers-virtual-hosting:
 
-Host headers and virtual hosting
-================================
+Host header validation
+======================
 
-Django uses the ``Host`` header provided by the client to construct URLs
-in certain cases. While these values are sanitized to prevent Cross
-Site Scripting attacks, they can be used for Cross-Site Request
-Forgery and cache poisoning attacks in some circumstances. We
-recommend you ensure your Web server is configured such that:
+Django uses the ``Host`` header provided by the client to construct URLs in
+certain cases. While these values are sanitized to prevent Cross Site Scripting
+attacks, a fake ``Host`` value can be used for Cross-Site Request Forgery,
+cache poisoning attacks, and poisoning links in emails.
 
-* It always validates incoming HTTP ``Host`` headers against the expected
-  host name.
-* Disallows requests with no ``Host`` header.
-* Is *not* configured with a catch-all virtual host that forwards requests
-  to a Django application.
+Because even seemingly-secure webserver configurations are susceptible to fake
+``Host`` headers, Django validates ``Host`` headers against the
+:setting:`ALLOWED_HOSTS` setting in the
+:meth:`django.http.HttpRequest.get_host()` method.
+
+This validation only applies via :meth:`~django.http.HttpRequest.get_host()`;
+if your code accesses the ``Host`` header directly from ``request.META`` you
+are bypassing this security protection.
+
+For more details see the full :setting:`ALLOWED_HOSTS` documentation.
+
+.. warning::
+
+   Previous versions of this document recommended configuring your webserver to
+   ensure it validates incoming HTTP ``Host`` headers. While this is still
+   recommended, in many common webservers a configuration that seems to
+   validate the ``Host`` header may not in fact do so. For instance, even if
+   Apache is configured such that your Django site is served from a non-default
+   virtual host with the ``ServerName`` set, it is still possible for an HTTP
+   request to match this virtual host and supply a fake ``Host`` header. Thus,
+   Django now requires that you set :setting:`ALLOWED_HOSTS` explicitly rather
+   than relying on webserver configuration.
 
 Additionally, as of 1.3.1, Django requires you to explicitly enable support for
-the ``X-Forwarded-Host`` header if your configuration requires it.
-
-Configuration for Apache
-------------------------
-
-The easiest way to get the described behavior in Apache is as follows. Create
-a `virtual host`_ using the ServerName_ and ServerAlias_ directives to restrict
-the domains Apache reacts to. Please keep in mind that while the directives do
-support ports the match is only performed against the hostname. This means that
-the ``Host`` header could still contain a port pointing to another webserver on
-the same machine. The next step is to make sure that your newly created virtual
-host is not also the default virtual host. Apache uses the first virtual host
-found in the configuration file as default virtual host.  As such you have to
-ensure that you have another virtual host which will act as catch-all virtual
-host. Just add one if you do not have one already, there is nothing special
-about it aside from ensuring it is the first virtual host in the configuration
-file. Debian/Ubuntu users usually don't have to take any action, since Apache
-ships with a default virtual host in ``sites-available`` which is linked into
-``sites-enabled`` as ``000-default`` and included from ``apache2.conf``. Just
-make sure not to name your site ``000-abc``, since files are included in
-alphabetical order.
-
-.. _virtual host: http://httpd.apache.org/docs/2.2/vhosts/
-.. _ServerName: http://httpd.apache.org/docs/2.2/mod/core.html#servername
-.. _ServerAlias: http://httpd.apache.org/docs/2.2/mod/core.html#serveralias
+the ``X-Forwarded-Host`` header (via the :setting:`USE_X_FORWARDED_HOST`
+setting) if your configuration requires it.
 
 
 .. _additional-security-topics:

+ 4 - 0
tests/regressiontests/csrf_tests/tests.py

@@ -7,6 +7,7 @@ from django.http import HttpRequest, HttpResponse
 from django.middleware.csrf import CsrfViewMiddleware, CSRF_KEY_LENGTH
 from django.template import RequestContext, Template
 from django.test import TestCase
+from django.test.utils import override_settings
 from django.views.decorators.csrf import csrf_exempt, requires_csrf_token, ensure_csrf_cookie
 
 
@@ -269,6 +270,7 @@ class CsrfViewMiddlewareTest(TestCase):
         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'])
     def test_https_bad_referer(self):
         """
         Test that a POST HTTPS request with a bad referer is rejected
@@ -281,6 +283,7 @@ class CsrfViewMiddlewareTest(TestCase):
         self.assertNotEqual(None, req2)
         self.assertEqual(403, req2.status_code)
 
+    @override_settings(ALLOWED_HOSTS=['www.example.com'])
     def test_https_good_referer(self):
         """
         Test that a POST HTTPS request with a good referer is accepted
@@ -292,6 +295,7 @@ class CsrfViewMiddlewareTest(TestCase):
         req2 = CsrfViewMiddleware().process_view(req, post_form_view, (), {})
         self.assertEqual(None, req2)
 
+    @override_settings(ALLOWED_HOSTS=['www.example.com'])
     def test_https_good_referer_2(self):
         """
         Test that a POST HTTPS request with a good referer is accepted

+ 22 - 2
tests/regressiontests/requests/tests.py

@@ -84,7 +84,13 @@ class RequestsTests(unittest.TestCase):
         self.assertEqual(request.build_absolute_uri(location="/path/with:colons"),
             'http://www.example.com/path/with:colons')
 
-    @override_settings(USE_X_FORWARDED_HOST=False)
+    @override_settings(
+        USE_X_FORWARDED_HOST=False,
+        ALLOWED_HOSTS=[
+            'forward.com', 'example.com', 'internal.com', '12.34.56.78',
+            '[2001:19f0:feee::dead:beef:cafe]', 'xn--4ca9at.com',
+            '.multitenant.com', 'INSENSITIVE.com',
+            ])
     def test_http_get_host(self):
         # Check if X_FORWARDED_HOST is provided.
         request = HttpRequest()
@@ -131,6 +137,9 @@ class RequestsTests(unittest.TestCase):
             '[2001:19f0:feee::dead:beef:cafe]',
             '[2001:19f0:feee::dead:beef:cafe]:8080',
             'xn--4ca9at.com', # Punnycode for öäü.com
+            'anything.multitenant.com',
+            'multitenant.com',
+            'insensitive.com',
         ]
 
         poisoned_hosts = [
@@ -139,6 +148,7 @@ class RequestsTests(unittest.TestCase):
             'example.com:dr.frankenstein@evil.tld:80',
             'example.com:80/badpath',
             'example.com: recovermypassword.com',
+            'other.com', # not in ALLOWED_HOSTS
         ]
 
         for host in legit_hosts:
@@ -156,7 +166,7 @@ class RequestsTests(unittest.TestCase):
                 }
                 request.get_host()
 
-    @override_settings(USE_X_FORWARDED_HOST=True)
+    @override_settings(USE_X_FORWARDED_HOST=True, ALLOWED_HOSTS=['*'])
     def test_http_get_host_with_x_forwarded_host(self):
         # Check if X_FORWARDED_HOST is provided.
         request = HttpRequest()
@@ -229,6 +239,16 @@ class RequestsTests(unittest.TestCase):
                 request.get_host()
 
 
+    @override_settings(DEBUG=True, ALLOWED_HOSTS=[])
+    def test_host_validation_disabled_in_debug_mode(self):
+        """If ALLOWED_HOSTS is empty and DEBUG is True, all hosts pass."""
+        request = HttpRequest()
+        request.META = {
+            'HTTP_HOST': 'example.com',
+        }
+        self.assertEqual(request.get_host(), 'example.com')
+
+
     def test_near_expiration(self):
         "Cookie will expire when an near expiration time is provided"
         response = HttpResponse()