Skip to content

Commit

Permalink
[1.4.x] Added ALLOWED_HOSTS setting for HTTP host header validation.
Browse files Browse the repository at this point in the history
This is a security fix; disclosure and advisory coming shortly.
  • Loading branch information
carljm committed Feb 19, 2013
1 parent 57b62a7 commit 9936fdb
Show file tree
Hide file tree
Showing 13 changed files with 314 additions and 175 deletions.
4 changes: 4 additions & 0 deletions django/conf/global_settings.py
Expand Up @@ -29,6 +29,10 @@
# * 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
Expand Down
4 changes: 4 additions & 0 deletions django/conf/project_template/project_name/settings.py
Expand Up @@ -20,6 +20,10 @@
}
}

# 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.
Expand Down
1 change: 1 addition & 0 deletions django/contrib/auth/tests/views.py
Expand Up @@ -107,6 +107,7 @@ def test_email_found_custom_from(self):
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/',
Expand Down
2 changes: 2 additions & 0 deletions django/contrib/contenttypes/tests.py
Expand Up @@ -9,6 +9,7 @@
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):
Expand Down Expand Up @@ -114,6 +115,7 @@ def test_get_for_models_full_cache(self):
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"
Expand Down
2 changes: 2 additions & 0 deletions django/contrib/sites/tests.py
Expand Up @@ -3,6 +3,7 @@
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):
Expand Down Expand Up @@ -39,6 +40,7 @@ def test_site_cache(self):
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()
Expand Down
51 changes: 46 additions & 5 deletions django/http/__init__.py
Expand Up @@ -215,11 +215,12 @@ def get_host(self):
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.
Expand Down Expand Up @@ -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
6 changes: 6 additions & 0 deletions django/test/utils.py
Expand Up @@ -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()
Expand All @@ -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


Expand Down
36 changes: 36 additions & 0 deletions docs/ref/settings.txt
Expand Up @@ -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
Expand Down
39 changes: 39 additions & 0 deletions 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.
1 change: 1 addition & 0 deletions docs/releases/index.txt
Expand Up @@ -20,6 +20,7 @@ Final releases
.. toctree::
:maxdepth: 1

1.4.4
1.4.2
1.4.1
1.4
Expand Down
68 changes: 30 additions & 38 deletions docs/topics/security.txt
Expand Up @@ -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.

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
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 (via the :setting:`USE_X_FORWARDED_HOST`
setting) if your configuration requires it.



Expand Down
4 changes: 4 additions & 0 deletions tests/regressiontests/csrf_tests/tests.py
Expand Up @@ -7,6 +7,7 @@
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


Expand Down Expand Up @@ -267,6 +268,7 @@ def test_token_node_with_new_csrf_cookie(self):
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
Expand All @@ -279,6 +281,7 @@ def test_https_bad_referer(self):
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
Expand All @@ -290,6 +293,7 @@ def test_https_good_referer(self):
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
Expand Down

1 comment on commit 9936fdb

@xiyang1081
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is very good!

Please sign in to comment.