mirror of
https://github.com/django/django.git
synced 2025-07-07 03:09:22 +00:00
[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:
parent
6e70f67470
commit
27cd872e6e
@ -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).
|
# systems may support all possibilities).
|
||||||
|
@ -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:
|
# Local time zone for this installation. Choices can be found here:
|
||||||
# http://en.wikipedia.org/wiki/List_of_tz_zones_by_name
|
# http://en.wikipedia.org/wiki/List_of_tz_zones_by_name
|
||||||
# although not all choices may be available on all operating systems.
|
# although not all choices may be available on all operating systems.
|
||||||
|
@ -168,11 +168,15 @@ class HttpRequest(object):
|
|||||||
if server_port != (self.is_secure() and '443' or '80'):
|
if server_port != (self.is_secure() and '443' or '80'):
|
||||||
host = '%s:%s' % (host, server_port)
|
host = '%s:%s' % (host, server_port)
|
||||||
|
|
||||||
# Disallow potentially poisoned hostnames.
|
if settings.DEBUG:
|
||||||
if not host_validation_re.match(host.lower()):
|
allowed_hosts = ['*']
|
||||||
raise SuspiciousOperation('Invalid HTTP_HOST header: %s' % host)
|
else:
|
||||||
|
allowed_hosts = settings.ALLOWED_HOSTS
|
||||||
|
if validate_host(host, allowed_hosts):
|
||||||
return host
|
return host
|
||||||
|
else:
|
||||||
|
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.
|
||||||
@ -704,3 +708,43 @@ def str_to_unicode(s, encoding):
|
|||||||
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
|
||||||
|
@ -76,6 +76,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()
|
||||||
@ -97,6 +100,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
|
||||||
|
|
||||||
|
|
||||||
|
@ -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.
|
Note that Django will e-mail *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: ``['*']``
|
||||||
|
|
||||||
|
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
|
||||||
|
31
docs/releases/1.3.6.txt
Normal file
31
docs/releases/1.3.6.txt
Normal 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.
|
@ -19,6 +19,7 @@ Final releases
|
|||||||
.. toctree::
|
.. toctree::
|
||||||
:maxdepth: 1
|
:maxdepth: 1
|
||||||
|
|
||||||
|
1.3.6
|
||||||
1.3.1
|
1.3.1
|
||||||
1.3
|
1.3
|
||||||
|
|
||||||
|
@ -63,17 +63,23 @@ class RequestsTests(unittest.TestCase):
|
|||||||
'http://www.example.com/path/with:colons')
|
'http://www.example.com/path/with:colons')
|
||||||
|
|
||||||
def test_http_get_host(self):
|
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:
|
try:
|
||||||
settings.USE_X_FORWARDED_HOST = False
|
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.
|
# Check if X_FORWARDED_HOST is provided.
|
||||||
request = HttpRequest()
|
request = HttpRequest()
|
||||||
request.META = {
|
request.META = {
|
||||||
u'HTTP_X_FORWARDED_HOST': u'forward.com',
|
'HTTP_X_FORWARDED_HOST': 'forward.com',
|
||||||
u'HTTP_HOST': u'example.com',
|
'HTTP_HOST': 'example.com',
|
||||||
u'SERVER_NAME': u'internal.com',
|
'SERVER_NAME': 'internal.com',
|
||||||
u'SERVER_PORT': 80,
|
'SERVER_PORT': 80,
|
||||||
}
|
}
|
||||||
# X_FORWARDED_HOST is ignored.
|
# X_FORWARDED_HOST is ignored.
|
||||||
self.assertEqual(request.get_host(), 'example.com')
|
self.assertEqual(request.get_host(), 'example.com')
|
||||||
@ -81,25 +87,25 @@ class RequestsTests(unittest.TestCase):
|
|||||||
# Check if X_FORWARDED_HOST isn't provided.
|
# Check if X_FORWARDED_HOST isn't provided.
|
||||||
request = HttpRequest()
|
request = HttpRequest()
|
||||||
request.META = {
|
request.META = {
|
||||||
u'HTTP_HOST': u'example.com',
|
'HTTP_HOST': 'example.com',
|
||||||
u'SERVER_NAME': u'internal.com',
|
'SERVER_NAME': 'internal.com',
|
||||||
u'SERVER_PORT': 80,
|
'SERVER_PORT': 80,
|
||||||
}
|
}
|
||||||
self.assertEqual(request.get_host(), 'example.com')
|
self.assertEqual(request.get_host(), 'example.com')
|
||||||
|
|
||||||
# Check if HTTP_HOST isn't provided.
|
# Check if HTTP_HOST isn't provided.
|
||||||
request = HttpRequest()
|
request = HttpRequest()
|
||||||
request.META = {
|
request.META = {
|
||||||
u'SERVER_NAME': u'internal.com',
|
'SERVER_NAME': 'internal.com',
|
||||||
u'SERVER_PORT': 80,
|
'SERVER_PORT': 80,
|
||||||
}
|
}
|
||||||
self.assertEqual(request.get_host(), 'internal.com')
|
self.assertEqual(request.get_host(), 'internal.com')
|
||||||
|
|
||||||
# Check if HTTP_HOST isn't provided, and we're on a nonstandard port
|
# Check if HTTP_HOST isn't provided, and we're on a nonstandard port
|
||||||
request = HttpRequest()
|
request = HttpRequest()
|
||||||
request.META = {
|
request.META = {
|
||||||
u'SERVER_NAME': u'internal.com',
|
'SERVER_NAME': 'internal.com',
|
||||||
u'SERVER_PORT': 8042,
|
'SERVER_PORT': 8042,
|
||||||
}
|
}
|
||||||
self.assertEqual(request.get_host(), 'internal.com: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]',
|
||||||
'[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 = [
|
||||||
@ -120,6 +129,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:
|
||||||
@ -130,29 +140,31 @@ class RequestsTests(unittest.TestCase):
|
|||||||
request.get_host()
|
request.get_host()
|
||||||
|
|
||||||
for host in poisoned_hosts:
|
for host in poisoned_hosts:
|
||||||
def test_host_poisoning():
|
def _test():
|
||||||
request = HttpRequest()
|
request = HttpRequest()
|
||||||
request.META = {
|
request.META = {
|
||||||
'HTTP_HOST': host,
|
'HTTP_HOST': host,
|
||||||
}
|
}
|
||||||
request.get_host()
|
request.get_host()
|
||||||
self.assertRaises(SuspiciousOperation, test_host_poisoning)
|
self.assertRaises(SuspiciousOperation, _test)
|
||||||
|
|
||||||
finally:
|
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):
|
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:
|
try:
|
||||||
settings.USE_X_FORWARDED_HOST = True
|
settings.USE_X_FORWARDED_HOST = True
|
||||||
|
settings.ALLOWED_HOSTS = ['*']
|
||||||
|
|
||||||
# Check if X_FORWARDED_HOST is provided.
|
# Check if X_FORWARDED_HOST is provided.
|
||||||
request = HttpRequest()
|
request = HttpRequest()
|
||||||
request.META = {
|
request.META = {
|
||||||
u'HTTP_X_FORWARDED_HOST': u'forward.com',
|
'HTTP_X_FORWARDED_HOST': 'forward.com',
|
||||||
u'HTTP_HOST': u'example.com',
|
'HTTP_HOST': 'example.com',
|
||||||
u'SERVER_NAME': u'internal.com',
|
'SERVER_NAME': 'internal.com',
|
||||||
u'SERVER_PORT': 80,
|
'SERVER_PORT': 80,
|
||||||
}
|
}
|
||||||
# X_FORWARDED_HOST is obeyed.
|
# X_FORWARDED_HOST is obeyed.
|
||||||
self.assertEqual(request.get_host(), 'forward.com')
|
self.assertEqual(request.get_host(), 'forward.com')
|
||||||
@ -160,25 +172,25 @@ class RequestsTests(unittest.TestCase):
|
|||||||
# Check if X_FORWARDED_HOST isn't provided.
|
# Check if X_FORWARDED_HOST isn't provided.
|
||||||
request = HttpRequest()
|
request = HttpRequest()
|
||||||
request.META = {
|
request.META = {
|
||||||
u'HTTP_HOST': u'example.com',
|
'HTTP_HOST': 'example.com',
|
||||||
u'SERVER_NAME': u'internal.com',
|
'SERVER_NAME': 'internal.com',
|
||||||
u'SERVER_PORT': 80,
|
'SERVER_PORT': 80,
|
||||||
}
|
}
|
||||||
self.assertEqual(request.get_host(), 'example.com')
|
self.assertEqual(request.get_host(), 'example.com')
|
||||||
|
|
||||||
# Check if HTTP_HOST isn't provided.
|
# Check if HTTP_HOST isn't provided.
|
||||||
request = HttpRequest()
|
request = HttpRequest()
|
||||||
request.META = {
|
request.META = {
|
||||||
u'SERVER_NAME': u'internal.com',
|
'SERVER_NAME': 'internal.com',
|
||||||
u'SERVER_PORT': 80,
|
'SERVER_PORT': 80,
|
||||||
}
|
}
|
||||||
self.assertEqual(request.get_host(), 'internal.com')
|
self.assertEqual(request.get_host(), 'internal.com')
|
||||||
|
|
||||||
# Check if HTTP_HOST isn't provided, and we're on a nonstandard port
|
# Check if HTTP_HOST isn't provided, and we're on a nonstandard port
|
||||||
request = HttpRequest()
|
request = HttpRequest()
|
||||||
request.META = {
|
request.META = {
|
||||||
u'SERVER_NAME': u'internal.com',
|
'SERVER_NAME': 'internal.com',
|
||||||
u'SERVER_PORT': 8042,
|
'SERVER_PORT': 8042,
|
||||||
}
|
}
|
||||||
self.assertEqual(request.get_host(), 'internal.com:8042')
|
self.assertEqual(request.get_host(), 'internal.com:8042')
|
||||||
|
|
||||||
@ -209,16 +221,33 @@ class RequestsTests(unittest.TestCase):
|
|||||||
request.get_host()
|
request.get_host()
|
||||||
|
|
||||||
for host in poisoned_hosts:
|
for host in poisoned_hosts:
|
||||||
def test_host_poisoning():
|
def _test():
|
||||||
request = HttpRequest()
|
request = HttpRequest()
|
||||||
request.META = {
|
request.META = {
|
||||||
'HTTP_HOST': host,
|
'HTTP_HOST': host,
|
||||||
}
|
}
|
||||||
request.get_host()
|
request.get_host()
|
||||||
self.assertRaises(SuspiciousOperation, test_host_poisoning)
|
self.assertRaises(SuspiciousOperation, _test)
|
||||||
|
|
||||||
finally:
|
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):
|
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"
|
||||||
|
Loading…
x
Reference in New Issue
Block a user