[1.3.x] Added 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 12:25:52 -07:00 committed by Aymeric Augustin
parent 6e70f67470
commit 27cd872e6e
8 changed files with 192 additions and 37 deletions

View File

@ -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).

View File

@ -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.

View File

@ -168,11 +168,15 @@ 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
if settings.DEBUG:
allowed_hosts = ['*']
else:
allowed_hosts = 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.
@ -704,3 +708,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

View File

@ -76,6 +76,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()
@ -97,6 +100,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

View File

@ -82,6 +82,42 @@ of (Full name, e-mail address). Example::
Note that Django will e-mail *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

31
docs/releases/1.3.6.txt Normal file
View File

@ -0,0 +1,31 @@
==========================
Django 1.3.6 release notes
==========================
*February 19, 2013*
This is the sixth bugfix/security release in the Django 1.3 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.3.6 adds a new setting, ``ALLOWED_HOSTS``, which
should contain 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.3.6 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.

View File

@ -19,6 +19,7 @@ Final releases
.. toctree::
:maxdepth: 1
1.3.6
1.3.1
1.3

View File

@ -63,17 +63,23 @@ class RequestsTests(unittest.TestCase):
'http://www.example.com/path/with:colons')
def test_http_get_host(self):
old_USE_X_FORWARDED_HOST = settings.USE_X_FORWARDED_HOST
_old_USE_X_FORWARDED_HOST = settings.USE_X_FORWARDED_HOST
_old_ALLOWED_HOSTS = settings.ALLOWED_HOSTS
try:
settings.USE_X_FORWARDED_HOST = False
settings.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',
]
# Check if X_FORWARDED_HOST is provided.
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_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')
@ -81,25 +87,25 @@ class RequestsTests(unittest.TestCase):
# 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,
'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 = {
u'SERVER_NAME': u'internal.com',
u'SERVER_PORT': 80,
'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 = {
u'SERVER_NAME': u'internal.com',
u'SERVER_PORT': 8042,
'SERVER_NAME': 'internal.com',
'SERVER_PORT': 8042,
}
self.assertEqual(request.get_host(), 'internal.com:8042')
@ -112,6 +118,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 = [
@ -120,6 +129,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:
@ -130,29 +140,31 @@ class RequestsTests(unittest.TestCase):
request.get_host()
for host in poisoned_hosts:
def test_host_poisoning():
def _test():
request = HttpRequest()
request.META = {
'HTTP_HOST': host,
}
request.get_host()
self.assertRaises(SuspiciousOperation, test_host_poisoning)
self.assertRaises(SuspiciousOperation, _test)
finally:
settings.USE_X_FORWARDED_HOST = old_USE_X_FORWARDED_HOST
settings.ALLOWED_HOSTS = _old_ALLOWED_HOSTS
settings.USE_X_FORWARDED_HOST = _old_USE_X_FORWARDED_HOST
def test_http_get_host_with_x_forwarded_host(self):
old_USE_X_FORWARDED_HOST = settings.USE_X_FORWARDED_HOST
_old_USE_X_FORWARDED_HOST = settings.USE_X_FORWARDED_HOST
_old_ALLOWED_HOSTS = settings.ALLOWED_HOSTS
try:
settings.USE_X_FORWARDED_HOST = True
settings.ALLOWED_HOSTS = ['*']
# Check if X_FORWARDED_HOST is provided.
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_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')
@ -160,25 +172,25 @@ class RequestsTests(unittest.TestCase):
# 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,
'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 = {
u'SERVER_NAME': u'internal.com',
u'SERVER_PORT': 80,
'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 = {
u'SERVER_NAME': u'internal.com',
u'SERVER_PORT': 8042,
'SERVER_NAME': 'internal.com',
'SERVER_PORT': 8042,
}
self.assertEqual(request.get_host(), 'internal.com:8042')
@ -209,16 +221,33 @@ class RequestsTests(unittest.TestCase):
request.get_host()
for host in poisoned_hosts:
def test_host_poisoning():
def _test():
request = HttpRequest()
request.META = {
'HTTP_HOST': host,
}
request.get_host()
self.assertRaises(SuspiciousOperation, test_host_poisoning)
self.assertRaises(SuspiciousOperation, _test)
finally:
settings.USE_X_FORWARDED_HOST = old_USE_X_FORWARDED_HOST
settings.ALLOWED_HOSTS = _old_ALLOWED_HOSTS
settings.USE_X_FORWARDED_HOST = _old_USE_X_FORWARDED_HOST
def test_host_validation_disabled_in_debug_mode(self):
"""If ALLOWED_HOSTS is empty and DEBUG is True, all hosts pass."""
_old_DEBUG = settings.DEBUG
_old_ALLOWED_HOSTS = settings.ALLOWED_HOSTS
try:
settings.DEBUG = True
settings.ALLOWED_HOSTS = []
request = HttpRequest()
request.META = {
'HTTP_HOST': 'example.com',
}
self.assertEqual(request.get_host(), 'example.com')
finally:
settings.DEBUG = _old_DEBUG
settings.ALLOWED_HOSTS = _old_ALLOWED_HOSTS
def test_near_expiration(self):
"Cookie will expire when an near expiration time is provided"