From 9936fdb11d0bbf0bd242f259bfb97bbf849d16f8 Mon Sep 17 00:00:00 2001 From: Carl Meyer Date: Sat, 9 Feb 2013 11:32:51 -0700 Subject: [PATCH] [1.4.x] Added ALLOWED_HOSTS setting for HTTP host header validation. This is a security fix; disclosure and advisory coming shortly. --- django/conf/global_settings.py | 4 + .../project_template/project_name/settings.py | 4 + django/contrib/auth/tests/views.py | 1 + django/contrib/contenttypes/tests.py | 2 + django/contrib/sites/tests.py | 2 + django/http/__init__.py | 51 +++- django/test/utils.py | 6 + docs/ref/settings.txt | 36 +++ docs/releases/1.4.4.txt | 39 +++ docs/releases/index.txt | 1 + docs/topics/security.txt | 68 ++--- tests/regressiontests/csrf_tests/tests.py | 4 + tests/regressiontests/requests/tests.py | 271 +++++++++--------- 13 files changed, 314 insertions(+), 175 deletions(-) create mode 100644 docs/releases/1.4.4.txt diff --git a/django/conf/global_settings.py b/django/conf/global_settings.py index bd85c121b8..026e39b7c9 100644 --- a/django/conf/global_settings.py +++ b/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 diff --git a/django/conf/project_template/project_name/settings.py b/django/conf/project_template/project_name/settings.py index 0eccc4eaf5..5780aca012 100644 --- a/django/conf/project_template/project_name/settings.py +++ b/django/conf/project_template/project_name/settings.py @@ -20,6 +20,10 @@ DATABASES = { } } +# 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 = [] + # Local time zone for this installation. Choices can be found here: # http://en.wikipedia.org/wiki/List_of_tz_zones_by_name # although not all choices may be available on all operating systems. diff --git a/django/contrib/auth/tests/views.py b/django/contrib/auth/tests/views.py index d295bb8c10..603d380e9d 100644 --- a/django/contrib/auth/tests/views.py +++ b/django/contrib/auth/tests/views.py @@ -107,6 +107,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/', diff --git a/django/contrib/contenttypes/tests.py b/django/contrib/contenttypes/tests.py index 3b7906c812..66226a78b8 100644 --- a/django/contrib/contenttypes/tests.py +++ b/django/contrib/contenttypes/tests.py @@ -9,6 +9,7 @@ from django.contrib.sites.models import Site from django.http import HttpRequest, Http404 from django.test import TestCase from django.utils.encoding import smart_str +from django.test.utils import override_settings class FooWithoutUrl(models.Model): @@ -114,6 +115,7 @@ class ContentTypesTests(TestCase): FooWithUrl: ContentType.objects.get_for_model(FooWithUrl), }) + @override_settings(ALLOWED_HOSTS=['example.com']) def test_shortcut_view(self): """ Check that the shortcut view (used for the admin "view on site" diff --git a/django/contrib/sites/tests.py b/django/contrib/sites/tests.py index 828badb386..1fd52e657e 100644 --- a/django/contrib/sites/tests.py +++ b/django/contrib/sites/tests.py @@ -3,6 +3,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): @@ -39,6 +40,7 @@ class SitesFrameworkTests(TestCase): site = Site.objects.get_current() self.assertEqual(u"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() diff --git a/django/http/__init__.py b/django/http/__init__.py index da993eb8d3..4f5fbe6cf3 100644 --- a/django/http/__init__.py +++ b/django/http/__init__.py @@ -215,11 +215,12 @@ class HttpRequest(object): if server_port != (self.is_secure() and '443' or '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. @@ -799,3 +800,43 @@ def str_to_unicode(s, encoding): 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 diff --git a/django/test/utils.py b/django/test/utils.py index ed5ab590a7..6d6b6e177f 100644 --- a/django/test/utils.py +++ b/django/test/utils.py @@ -75,6 +75,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() @@ -93,6 +96,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 diff --git a/docs/ref/settings.txt b/docs/ref/settings.txt index d7b100f0b9..f992eef3e7 100644 --- a/docs/ref/settings.txt +++ b/docs/ref/settings.txt @@ -68,6 +68,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: ``['*']`` + +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 diff --git a/docs/releases/1.4.4.txt b/docs/releases/1.4.4.txt new file mode 100644 index 0000000000..3c5513bb8f --- /dev/null +++ b/docs/releases/1.4.4.txt @@ -0,0 +1,39 @@ +========================== +Django 1.4.4 release notes +========================== + +*February 19, 2013* + +This is the fourth bugfix/security release in the Django 1.4 series. + +Host header poisoning +--------------------- + +Some parts of Django -- independent of end-user-written applications -- make +use of full URLs, including domain name, which are generated from the HTTP Host +header. Django's documentation has for some time contained notes advising users +on how to configure webservers to ensure that only valid Host headers can reach +the Django application. However, it has been reported to us that even with the +recommended webserver configurations there are still techniques available for +tricking many common webservers into supplying the application with an +incorrect and possibly malicious Host header. + +For this reason, Django 1.4.4 adds a new setting, ``ALLOWED_HOSTS``, containing +an explicit list of valid host/domain names for this site. A request with a +Host header not matching an entry in this list will raise +``SuspiciousOperation`` if ``request.get_host()`` is called. For full details +see the documentation for the :setting:`ALLOWED_HOSTS` setting. + +The default value for this setting in Django 1.4.4 is `['*']` (matching any +host), for backwards-compatibility, but we strongly encourage all sites to set +a more restrictive value. + +This host validation is disabled when ``DEBUG`` is ``True`` or when running tests. + + +Other bugfixes and changes +========================== + +* Changed a SQL command syntax to be MySQL 4 compatible (#19702). +* Added backwards-compatibility with old unsalted MD5 passwords (#18144). +* Numerous documentation improvements and fixes. diff --git a/docs/releases/index.txt b/docs/releases/index.txt index 0b465a6d80..3571e03126 100644 --- a/docs/releases/index.txt +++ b/docs/releases/index.txt @@ -20,6 +20,7 @@ Final releases .. toctree:: :maxdepth: 1 + 1.4.4 1.4.2 1.4.1 1.4 diff --git a/docs/topics/security.txt b/docs/topics/security.txt index 0b5112803c..2a784bce85 100644 --- a/docs/topics/security.txt +++ b/docs/topics/security.txt @@ -149,48 +149,40 @@ server, there are some additional steps you may need: .. _additional-security-topics: -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. diff --git a/tests/regressiontests/csrf_tests/tests.py b/tests/regressiontests/csrf_tests/tests.py index 71400ead89..a605134fba 100644 --- a/tests/regressiontests/csrf_tests/tests.py +++ b/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 @@ -267,6 +268,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 @@ -279,6 +281,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 @@ -290,6 +293,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 diff --git a/tests/regressiontests/requests/tests.py b/tests/regressiontests/requests/tests.py index 5927cbb8bc..2c9873c9e2 100644 --- a/tests/regressiontests/requests/tests.py +++ b/tests/regressiontests/requests/tests.py @@ -14,7 +14,7 @@ from django.core.exceptions import SuspiciousOperation from django.core.handlers.wsgi import WSGIRequest, LimitedStream from django.http import HttpRequest, HttpResponse, parse_cookie, build_request_repr, UnreadablePostError from django.test import TransactionTestCase -from django.test.utils import get_warnings_state, restore_warnings_state +from django.test.utils import get_warnings_state, restore_warnings_state, override_settings from django.utils import unittest from django.utils.http import cookie_date from django.utils.timezone import utc @@ -109,161 +109,168 @@ 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, + 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): - old_USE_X_FORWARDED_HOST = settings.USE_X_FORWARDED_HOST - try: - settings.USE_X_FORWARDED_HOST = False + # Check if X_FORWARDED_HOST is provided. + request = HttpRequest() + request.META = { + 'HTTP_X_FORWARDED_HOST': 'forward.com', + 'HTTP_HOST': 'example.com', + 'SERVER_NAME': 'internal.com', + 'SERVER_PORT': 80, + } + # X_FORWARDED_HOST is ignored. + self.assertEqual(request.get_host(), 'example.com') - # Check if X_FORWARDED_HOST is provided. + # Check if X_FORWARDED_HOST isn't provided. + request = HttpRequest() + request.META = { + 'HTTP_HOST': 'example.com', + 'SERVER_NAME': 'internal.com', + 'SERVER_PORT': 80, + } + self.assertEqual(request.get_host(), 'example.com') + + # Check if HTTP_HOST isn't provided. + request = HttpRequest() + request.META = { + 'SERVER_NAME': 'internal.com', + 'SERVER_PORT': 80, + } + self.assertEqual(request.get_host(), 'internal.com') + + # Check if HTTP_HOST isn't provided, and we're on a nonstandard port + request = HttpRequest() + request.META = { + 'SERVER_NAME': 'internal.com', + 'SERVER_PORT': 8042, + } + self.assertEqual(request.get_host(), 'internal.com:8042') + + # Poisoned host headers are rejected as suspicious + legit_hosts = [ + 'example.com', + 'example.com:80', + '12.34.56.78', + '12.34.56.78:443', + '[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 = [ + 'example.com@evil.tld', + 'example.com:dr.frankenstein@evil.tld', + '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: request = HttpRequest() request.META = { - u'HTTP_X_FORWARDED_HOST': u'forward.com', - u'HTTP_HOST': u'example.com', - u'SERVER_NAME': u'internal.com', - u'SERVER_PORT': 80, + 'HTTP_HOST': host, } - # X_FORWARDED_HOST is ignored. - self.assertEqual(request.get_host(), 'example.com') + request.get_host() - # Check if X_FORWARDED_HOST isn't provided. - request = HttpRequest() - request.META = { - u'HTTP_HOST': u'example.com', - u'SERVER_NAME': u'internal.com', - u'SERVER_PORT': 80, - } - self.assertEqual(request.get_host(), 'example.com') - - # Check if HTTP_HOST isn't provided. - request = HttpRequest() - request.META = { - u'SERVER_NAME': u'internal.com', - u'SERVER_PORT': 80, - } - self.assertEqual(request.get_host(), 'internal.com') - - # Check if HTTP_HOST isn't provided, and we're on a nonstandard port - request = HttpRequest() - request.META = { - u'SERVER_NAME': u'internal.com', - u'SERVER_PORT': 8042, - } - self.assertEqual(request.get_host(), 'internal.com:8042') - - # Poisoned host headers are rejected as suspicious - legit_hosts = [ - 'example.com', - 'example.com:80', - '12.34.56.78', - '12.34.56.78:443', - '[2001:19f0:feee::dead:beef:cafe]', - '[2001:19f0:feee::dead:beef:cafe]:8080', - 'xn--4ca9at.com', # Punnycode for öäü.com - ] - - poisoned_hosts = [ - 'example.com@evil.tld', - 'example.com:dr.frankenstein@evil.tld', - 'example.com:dr.frankenstein@evil.tld:80', - 'example.com:80/badpath', - 'example.com: recovermypassword.com', - ] - - for host in legit_hosts: + for host in poisoned_hosts: + with self.assertRaises(SuspiciousOperation): request = HttpRequest() request.META = { 'HTTP_HOST': host, } request.get_host() - for host in poisoned_hosts: - with self.assertRaises(SuspiciousOperation): - request = HttpRequest() - request.META = { - 'HTTP_HOST': host, - } - request.get_host() - - finally: - settings.USE_X_FORWARDED_HOST = old_USE_X_FORWARDED_HOST - + @override_settings(USE_X_FORWARDED_HOST=True, ALLOWED_HOSTS=['*']) def test_http_get_host_with_x_forwarded_host(self): - old_USE_X_FORWARDED_HOST = settings.USE_X_FORWARDED_HOST - try: - settings.USE_X_FORWARDED_HOST = True + # Check if X_FORWARDED_HOST is provided. + request = HttpRequest() + request.META = { + 'HTTP_X_FORWARDED_HOST': 'forward.com', + 'HTTP_HOST': 'example.com', + 'SERVER_NAME': 'internal.com', + 'SERVER_PORT': 80, + } + # X_FORWARDED_HOST is obeyed. + self.assertEqual(request.get_host(), 'forward.com') - # Check if X_FORWARDED_HOST is provided. + # Check if X_FORWARDED_HOST isn't provided. + request = HttpRequest() + request.META = { + 'HTTP_HOST': 'example.com', + 'SERVER_NAME': 'internal.com', + 'SERVER_PORT': 80, + } + self.assertEqual(request.get_host(), 'example.com') + + # Check if HTTP_HOST isn't provided. + request = HttpRequest() + request.META = { + 'SERVER_NAME': 'internal.com', + 'SERVER_PORT': 80, + } + self.assertEqual(request.get_host(), 'internal.com') + + # Check if HTTP_HOST isn't provided, and we're on a nonstandard port + request = HttpRequest() + request.META = { + 'SERVER_NAME': 'internal.com', + 'SERVER_PORT': 8042, + } + self.assertEqual(request.get_host(), 'internal.com:8042') + + # Poisoned host headers are rejected as suspicious + legit_hosts = [ + 'example.com', + 'example.com:80', + '12.34.56.78', + '12.34.56.78:443', + '[2001:19f0:feee::dead:beef:cafe]', + '[2001:19f0:feee::dead:beef:cafe]:8080', + 'xn--4ca9at.com', # Punnycode for öäü.com + ] + + poisoned_hosts = [ + 'example.com@evil.tld', + 'example.com:dr.frankenstein@evil.tld', + 'example.com:dr.frankenstein@evil.tld:80', + 'example.com:80/badpath', + 'example.com: recovermypassword.com', + ] + + for host in legit_hosts: request = HttpRequest() request.META = { - u'HTTP_X_FORWARDED_HOST': u'forward.com', - u'HTTP_HOST': u'example.com', - u'SERVER_NAME': u'internal.com', - u'SERVER_PORT': 80, + 'HTTP_HOST': host, } - # X_FORWARDED_HOST is obeyed. - self.assertEqual(request.get_host(), 'forward.com') + request.get_host() - # Check if X_FORWARDED_HOST isn't provided. - request = HttpRequest() - request.META = { - u'HTTP_HOST': u'example.com', - u'SERVER_NAME': u'internal.com', - u'SERVER_PORT': 80, - } - self.assertEqual(request.get_host(), 'example.com') - - # Check if HTTP_HOST isn't provided. - request = HttpRequest() - request.META = { - u'SERVER_NAME': u'internal.com', - u'SERVER_PORT': 80, - } - self.assertEqual(request.get_host(), 'internal.com') - - # Check if HTTP_HOST isn't provided, and we're on a nonstandard port - request = HttpRequest() - request.META = { - u'SERVER_NAME': u'internal.com', - u'SERVER_PORT': 8042, - } - self.assertEqual(request.get_host(), 'internal.com:8042') - - # Poisoned host headers are rejected as suspicious - legit_hosts = [ - 'example.com', - 'example.com:80', - '12.34.56.78', - '12.34.56.78:443', - '[2001:19f0:feee::dead:beef:cafe]', - '[2001:19f0:feee::dead:beef:cafe]:8080', - 'xn--4ca9at.com', # Punnycode for öäü.com - ] - - poisoned_hosts = [ - 'example.com@evil.tld', - 'example.com:dr.frankenstein@evil.tld', - 'example.com:dr.frankenstein@evil.tld:80', - 'example.com:80/badpath', - 'example.com: recovermypassword.com', - ] - - for host in legit_hosts: + for host in poisoned_hosts: + with self.assertRaises(SuspiciousOperation): request = HttpRequest() request.META = { 'HTTP_HOST': host, } request.get_host() - for host in poisoned_hosts: - with self.assertRaises(SuspiciousOperation): - request = HttpRequest() - request.META = { - 'HTTP_HOST': host, - } - request.get_host() - - finally: - settings.USE_X_FORWARDED_HOST = old_USE_X_FORWARDED_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"