summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCarl Meyer <carl@oddbird.net>2013-02-09 12:25:52 -0700
committerAymeric Augustin <aymeric.augustin@m4x.org>2013-02-12 11:41:43 +0100
commit27cd872e6e36a81d0bb6f5b8765a1705fecfc253 (patch)
tree3edf53617ba027f636fa1576987bed9537d4aa1a
parent6e70f67470d6d4baf87728702886f89ac075b73c (diff)
downloaddjango-27cd872e6e36a81d0bb6f5b8765a1705fecfc253.tar.gz
[1.3.x] Added ALLOWED_HOSTS setting for HTTP host header validation.
This is a security fix; disclosure and advisory coming shortly.
-rw-r--r--django/conf/global_settings.py4
-rw-r--r--django/conf/project_template/settings.py4
-rw-r--r--django/http/__init__.py54
-rw-r--r--django/test/utils.py6
-rw-r--r--docs/ref/settings.txt36
-rw-r--r--docs/releases/1.3.6.txt31
-rw-r--r--docs/releases/index.txt1
-rw-r--r--tests/regressiontests/requests/tests.py91
8 files changed, 191 insertions, 36 deletions
diff --git a/django/conf/global_settings.py b/django/conf/global_settings.py
index 5ef6886ac5..ec9ca437da 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).
diff --git a/django/conf/project_template/settings.py b/django/conf/project_template/settings.py
index 9d05ac2c8f..839039de9d 100644
--- a/django/conf/project_template/settings.py
+++ b/django/conf/project_template/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/http/__init__.py b/django/http/__init__.py
index a80750b57c..b3af718708 100644
--- a/django/http/__init__.py
+++ b/django/http/__init__.py
@@ -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
diff --git a/django/test/utils.py b/django/test/utils.py
index 6a41c1b70f..7be169962c 100644
--- a/django/test/utils.py
+++ b/django/test/utils.py
@@ -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
diff --git a/docs/ref/settings.txt b/docs/ref/settings.txt
index 24ccb5f949..68869f166c 100644
--- a/docs/ref/settings.txt
+++ b/docs/ref/settings.txt
@@ -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
diff --git a/docs/releases/1.3.6.txt b/docs/releases/1.3.6.txt
new file mode 100644
index 0000000000..c1e4bed2c7
--- /dev/null
+++ b/docs/releases/1.3.6.txt
@@ -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.
diff --git a/docs/releases/index.txt b/docs/releases/index.txt
index 40fe5b0e02..3f14936fea 100644
--- a/docs/releases/index.txt
+++ b/docs/releases/index.txt
@@ -19,6 +19,7 @@ Final releases
.. toctree::
:maxdepth: 1
+ 1.3.6
1.3.1
1.3
diff --git a/tests/regressiontests/requests/tests.py b/tests/regressiontests/requests/tests.py
index bbd2280c43..72c9bc256b 100644
--- a/tests/regressiontests/requests/tests.py
+++ b/tests/regressiontests/requests/tests.py
@@ -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.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.USE_X_FORWARDED_HOST = old_USE_X_FORWARDED_HOST
+ 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"