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

This is a security fix; disclosure and advisory coming shortly.
This commit is contained in:
Carl Meyer 2013-02-09 10:17:01 -07:00
parent 1add79bc40
commit d51fb74360
12 changed files with 169 additions and 44 deletions

View File

@ -29,6 +29,10 @@ ADMINS = ()
# * Receive x-headers # * Receive x-headers
INTERNAL_IPS = () 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: # 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 # 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 # systems may support all possibilities). When USE_TZ is True, this is

View File

@ -25,6 +25,10 @@ DEBUG = True
TEMPLATE_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 # Application definition

View File

@ -108,6 +108,7 @@ class PasswordResetTest(AuthViewsTestCase):
self.assertEqual(len(mail.outbox), 1) self.assertEqual(len(mail.outbox), 1)
self.assertEqual("staffmember@example.com", mail.outbox[0].from_email) self.assertEqual("staffmember@example.com", mail.outbox[0].from_email)
@override_settings(ALLOWED_HOSTS=['adminsite.com'])
def test_admin_reset(self): 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." "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/', response = self.client.post('/admin_password_reset/',

View File

@ -6,6 +6,7 @@ from django.contrib.contenttypes.views import shortcut
from django.contrib.sites.models import Site, get_current_site from django.contrib.sites.models import Site, get_current_site
from django.http import HttpRequest, Http404 from django.http import HttpRequest, Http404
from django.test import TestCase from django.test import TestCase
from django.test.utils import override_settings
from django.utils.http import urlquote from django.utils.http import urlquote
from django.utils import six from django.utils import six
from django.utils.encoding import python_2_unicode_compatible 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): def test_shortcut_view(self):
""" """
Check that the shortcut view (used for the admin "view on site" Check that the shortcut view (used for the admin "view on site"

View File

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

View File

@ -64,11 +64,12 @@ class HttpRequest(object):
if server_port != ('443' if self.is_secure() else '80'): if server_port != ('443' if self.is_secure() else '80'):
host = '%s:%s' % (host, server_port) host = '%s:%s' % (host, server_port)
# Disallow potentially poisoned hostnames. allowed_hosts = ['*'] if settings.DEBUG else settings.ALLOWED_HOSTS
if not host_validation_re.match(host.lower()): if validate_host(host, allowed_hosts):
raise SuspiciousOperation('Invalid HTTP_HOST header: %s' % host) return host
else:
return host raise SuspiciousOperation(
"Invalid HTTP_HOST header (you may need to set ALLOWED_HOSTS): %s" % host)
def get_full_path(self): def get_full_path(self):
# RFC 3986 requires query string arguments to be in the ASCII range. # 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') return six.text_type(s, encoding, 'replace')
else: else:
return s 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

View File

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

View File

@ -56,6 +56,42 @@ of (Full name, email address). Example::
Note that Django will email *all* of these people whenever an error happens. Note that Django will email *all* of these people whenever an error happens.
See :doc:`/howto/error-reporting` for more information. 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 .. setting:: ALLOWED_INCLUDE_ROOTS
ALLOWED_INCLUDE_ROOTS ALLOWED_INCLUDE_ROOTS

View File

@ -354,6 +354,16 @@ Backwards incompatible changes in 1.5
deprecation timeline for a given feature, its removal may appear as a deprecation timeline for a given feature, its removal may appear as a
backwards incompatible change. 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 Managers on abstract models
~~~~~~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~~

View File

@ -160,47 +160,40 @@ server, there are some additional steps you may need:
.. _host-headers-virtual-hosting: .. _host-headers-virtual-hosting:
Host headers and virtual hosting Host header validation
================================ ======================
Django uses the ``Host`` header provided by the client to construct URLs Django uses the ``Host`` header provided by the client to construct URLs in
in certain cases. While these values are sanitized to prevent Cross certain cases. While these values are sanitized to prevent Cross Site Scripting
Site Scripting attacks, they can be used for Cross-Site Request attacks, a fake ``Host`` value can be used for Cross-Site Request Forgery,
Forgery and cache poisoning attacks in some circumstances. We cache poisoning attacks, and poisoning links in emails.
recommend you ensure your Web server is configured such that:
* It always validates incoming HTTP ``Host`` headers against the expected Because even seemingly-secure webserver configurations are susceptible to fake
host name. ``Host`` headers, Django validates ``Host`` headers against the
* Disallows requests with no ``Host`` header. :setting:`ALLOWED_HOSTS` setting in the
* Is *not* configured with a catch-all virtual host that forwards requests :meth:`django.http.HttpRequest.get_host()` method.
to a Django application.
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 Additionally, as of 1.3.1, Django requires you to explicitly enable support for
the ``X-Forwarded-Host`` header if your configuration requires it. the ``X-Forwarded-Host`` header (via the :setting:`USE_X_FORWARDED_HOST`
setting) 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
.. _additional-security-topics: .. _additional-security-topics:

View File

@ -7,6 +7,7 @@ from django.http import HttpRequest, HttpResponse
from django.middleware.csrf import CsrfViewMiddleware, CSRF_KEY_LENGTH from django.middleware.csrf import CsrfViewMiddleware, CSRF_KEY_LENGTH
from django.template import RequestContext, Template from django.template import RequestContext, Template
from django.test import TestCase 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 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] csrf_cookie = resp2.cookies[settings.CSRF_COOKIE_NAME]
self._check_token_present(resp, csrf_id=csrf_cookie.value) self._check_token_present(resp, csrf_id=csrf_cookie.value)
@override_settings(ALLOWED_HOSTS=['www.example.com'])
def test_https_bad_referer(self): def test_https_bad_referer(self):
""" """
Test that a POST HTTPS request with a bad referer is rejected Test that a POST HTTPS request with a bad referer is rejected
@ -281,6 +283,7 @@ class CsrfViewMiddlewareTest(TestCase):
self.assertNotEqual(None, req2) self.assertNotEqual(None, req2)
self.assertEqual(403, req2.status_code) self.assertEqual(403, req2.status_code)
@override_settings(ALLOWED_HOSTS=['www.example.com'])
def test_https_good_referer(self): def test_https_good_referer(self):
""" """
Test that a POST HTTPS request with a good referer is accepted 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, (), {}) req2 = CsrfViewMiddleware().process_view(req, post_form_view, (), {})
self.assertEqual(None, req2) self.assertEqual(None, req2)
@override_settings(ALLOWED_HOSTS=['www.example.com'])
def test_https_good_referer_2(self): def test_https_good_referer_2(self):
""" """
Test that a POST HTTPS request with a good referer is accepted Test that a POST HTTPS request with a good referer is accepted

View File

@ -84,7 +84,13 @@ class RequestsTests(unittest.TestCase):
self.assertEqual(request.build_absolute_uri(location="/path/with:colons"), self.assertEqual(request.build_absolute_uri(location="/path/with:colons"),
'http://www.example.com/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): def test_http_get_host(self):
# Check if X_FORWARDED_HOST is provided. # Check if X_FORWARDED_HOST is provided.
request = HttpRequest() request = HttpRequest()
@ -131,6 +137,9 @@ class RequestsTests(unittest.TestCase):
'[2001:19f0:feee::dead:beef:cafe]', '[2001:19f0:feee::dead:beef:cafe]',
'[2001:19f0:feee::dead:beef:cafe]:8080', '[2001:19f0:feee::dead:beef:cafe]:8080',
'xn--4ca9at.com', # Punnycode for öäü.com 'xn--4ca9at.com', # Punnycode for öäü.com
'anything.multitenant.com',
'multitenant.com',
'insensitive.com',
] ]
poisoned_hosts = [ poisoned_hosts = [
@ -139,6 +148,7 @@ class RequestsTests(unittest.TestCase):
'example.com:dr.frankenstein@evil.tld:80', 'example.com:dr.frankenstein@evil.tld:80',
'example.com:80/badpath', 'example.com:80/badpath',
'example.com: recovermypassword.com', 'example.com: recovermypassword.com',
'other.com', # not in ALLOWED_HOSTS
] ]
for host in legit_hosts: for host in legit_hosts:
@ -156,7 +166,7 @@ class RequestsTests(unittest.TestCase):
} }
request.get_host() 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): def test_http_get_host_with_x_forwarded_host(self):
# Check if X_FORWARDED_HOST is provided. # Check if X_FORWARDED_HOST is provided.
request = HttpRequest() request = HttpRequest()
@ -229,6 +239,16 @@ class RequestsTests(unittest.TestCase):
request.get_host() 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): def test_near_expiration(self):
"Cookie will expire when an near expiration time is provided" "Cookie will expire when an near expiration time is provided"
response = HttpResponse() response = HttpResponse()