Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 27cd872

Browse files
carljmaaugustin
authored andcommitted
[1.3.x] Added ALLOWED_HOSTS setting for HTTP host header validation.
This is a security fix; disclosure and advisory coming shortly.
1 parent 6e70f67 commit 27cd872

File tree

8 files changed

+191
-36
lines changed

8 files changed

+191
-36
lines changed

django/conf/global_settings.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@
2929
# * Receive x-headers
3030
INTERNAL_IPS = ()
3131

32+
# Hosts/domain names that are valid for this site.
33+
# "*" matches anything, ".example.com" matches example.com and all subdomains
34+
ALLOWED_HOSTS = ['*']
35+
3236
# Local time zone for this installation. All choices can be found here:
3337
# http://en.wikipedia.org/wiki/List_of_tz_zones_by_name (although not all
3438
# systems may support all possibilities).

django/conf/project_template/settings.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@
2020
}
2121
}
2222

23+
# Hosts/domain names that are valid for this site; required if DEBUG is False
24+
# See https://docs.djangoproject.com/en/{{ docs_version }}/ref/settings/#allowed-hosts
25+
ALLOWED_HOSTS = []
26+
2327
# Local time zone for this installation. Choices can be found here:
2428
# http://en.wikipedia.org/wiki/List_of_tz_zones_by_name
2529
# although not all choices may be available on all operating systems.

django/http/__init__.py

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -168,11 +168,15 @@ def get_host(self):
168168
if server_port != (self.is_secure() and '443' or '80'):
169169
host = '%s:%s' % (host, server_port)
170170

171-
# Disallow potentially poisoned hostnames.
172-
if not host_validation_re.match(host.lower()):
173-
raise SuspiciousOperation('Invalid HTTP_HOST header: %s' % host)
174-
175-
return host
171+
if settings.DEBUG:
172+
allowed_hosts = ['*']
173+
else:
174+
allowed_hosts = settings.ALLOWED_HOSTS
175+
if validate_host(host, allowed_hosts):
176+
return host
177+
else:
178+
raise SuspiciousOperation(
179+
"Invalid HTTP_HOST header (you may need to set ALLOWED_HOSTS): %s" % host)
176180

177181
def get_full_path(self):
178182
# RFC 3986 requires query string arguments to be in the ASCII range.
@@ -704,3 +708,43 @@ def str_to_unicode(s, encoding):
704708
else:
705709
return s
706710

711+
def validate_host(host, allowed_hosts):
712+
"""
713+
Validate the given host header value for this site.
714+
715+
Check that the host looks valid and matches a host or host pattern in the
716+
given list of ``allowed_hosts``. Any pattern beginning with a period
717+
matches a domain and all its subdomains (e.g. ``.example.com`` matches
718+
``example.com`` and any subdomain), ``*`` matches anything, and anything
719+
else must match exactly.
720+
721+
Return ``True`` for a valid host, ``False`` otherwise.
722+
723+
"""
724+
# All validation is case-insensitive
725+
host = host.lower()
726+
727+
# Basic sanity check
728+
if not host_validation_re.match(host):
729+
return False
730+
731+
# Validate only the domain part.
732+
if host[-1] == ']':
733+
# It's an IPv6 address without a port.
734+
domain = host
735+
else:
736+
domain = host.rsplit(':', 1)[0]
737+
738+
for pattern in allowed_hosts:
739+
pattern = pattern.lower()
740+
match = (
741+
pattern == '*' or
742+
pattern.startswith('.') and (
743+
domain.endswith(pattern) or domain == pattern[1:]
744+
) or
745+
pattern == domain
746+
)
747+
if match:
748+
return True
749+
750+
return False

django/test/utils.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,9 @@ def setup_test_environment():
7676
mail.original_email_backend = settings.EMAIL_BACKEND
7777
settings.EMAIL_BACKEND = 'django.core.mail.backends.locmem.EmailBackend'
7878

79+
settings._original_allowed_hosts = settings.ALLOWED_HOSTS
80+
settings.ALLOWED_HOSTS = ['*']
81+
7982
mail.outbox = []
8083

8184
deactivate()
@@ -97,6 +100,9 @@ def teardown_test_environment():
97100
settings.EMAIL_BACKEND = mail.original_email_backend
98101
del mail.original_email_backend
99102

103+
settings.ALLOWED_HOSTS = settings._original_allowed_hosts
104+
del settings._original_allowed_hosts
105+
100106
del mail.outbox
101107

102108

docs/ref/settings.txt

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,42 @@ of (Full name, e-mail address). Example::
8282
Note that Django will e-mail *all* of these people whenever an error happens.
8383
See :doc:`/howto/error-reporting` for more information.
8484

85+
.. setting:: ALLOWED_HOSTS
86+
87+
ALLOWED_HOSTS
88+
-------------
89+
90+
Default: ``['*']``
91+
92+
A list of strings representing the host/domain names that this Django site can
93+
serve. This is a security measure to prevent an attacker from poisoning caches
94+
and password reset emails with links to malicious hosts by submitting requests
95+
with a fake HTTP ``Host`` header, which is possible even under many
96+
seemingly-safe webserver configurations.
97+
98+
Values in this list can be fully qualified names (e.g. ``'www.example.com'``),
99+
in which case they will be matched against the request's ``Host`` header
100+
exactly (case-insensitive, not including port). A value beginning with a period
101+
can be used as a subdomain wildcard: ``'.example.com'`` will match
102+
``example.com``, ``www.example.com``, and any other subdomain of
103+
``example.com``. A value of ``'*'`` will match anything; in this case you are
104+
responsible to provide your own validation of the ``Host`` header (perhaps in a
105+
middleware; if so this middleware must be listed first in
106+
:setting:`MIDDLEWARE_CLASSES`).
107+
108+
If the ``Host`` header (or ``X-Forwarded-Host`` if
109+
:setting:`USE_X_FORWARDED_HOST` is enabled) does not match any value in this
110+
list, the :meth:`django.http.HttpRequest.get_host()` method will raise
111+
:exc:`~django.core.exceptions.SuspiciousOperation`.
112+
113+
When :setting:`DEBUG` is ``True`` or when running tests, host validation is
114+
disabled; any host will be accepted. Thus it's usually only necessary to set it
115+
in production.
116+
117+
This validation only applies via :meth:`~django.http.HttpRequest.get_host()`;
118+
if your code accesses the ``Host`` header directly from ``request.META`` you
119+
are bypassing this security protection.
120+
85121
.. setting:: ALLOWED_INCLUDE_ROOTS
86122

87123
ALLOWED_INCLUDE_ROOTS

docs/releases/1.3.6.txt

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
==========================
2+
Django 1.3.6 release notes
3+
==========================
4+
5+
*February 19, 2013*
6+
7+
This is the sixth bugfix/security release in the Django 1.3 series.
8+
9+
Host header poisoning
10+
---------------------
11+
12+
Some parts of Django -- independent of end-user-written applications -- make
13+
use of full URLs, including domain name, which are generated from the HTTP Host
14+
header. Django's documentation has for some time contained notes advising users
15+
on how to configure webservers to ensure that only valid Host headers can reach
16+
the Django application. However, it has been reported to us that even with the
17+
recommended webserver configurations there are still techniques available for
18+
tricking many common webservers into supplying the application with an
19+
incorrect and possibly malicious Host header.
20+
21+
For this reason, Django 1.3.6 adds a new setting, ``ALLOWED_HOSTS``, which
22+
should contain an explicit list of valid host/domain names for this site. A
23+
request with a Host header not matching an entry in this list will raise
24+
``SuspiciousOperation`` if ``request.get_host()`` is called. For full details
25+
see the documentation for the :setting:`ALLOWED_HOSTS` setting.
26+
27+
The default value for this setting in Django 1.3.6 is `['*']` (matching any
28+
host), for backwards-compatibility, but we strongly encourage all sites to set
29+
a more restrictive value.
30+
31+
This host validation is disabled when ``DEBUG`` is ``True`` or when running tests.

docs/releases/index.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ Final releases
1919
.. toctree::
2020
:maxdepth: 1
2121

22+
1.3.6
2223
1.3.1
2324
1.3
2425

tests/regressiontests/requests/tests.py

Lines changed: 60 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -63,43 +63,49 @@ def test_httprequest_location(self):
6363
'http://www.example.com/path/with:colons')
6464

6565
def test_http_get_host(self):
66-
old_USE_X_FORWARDED_HOST = settings.USE_X_FORWARDED_HOST
66+
_old_USE_X_FORWARDED_HOST = settings.USE_X_FORWARDED_HOST
67+
_old_ALLOWED_HOSTS = settings.ALLOWED_HOSTS
6768
try:
6869
settings.USE_X_FORWARDED_HOST = False
70+
settings.ALLOWED_HOSTS = [
71+
'forward.com', 'example.com', 'internal.com', '12.34.56.78',
72+
'[2001:19f0:feee::dead:beef:cafe]', 'xn--4ca9at.com',
73+
'.multitenant.com', 'INSENSITIVE.com',
74+
]
6975

7076
# Check if X_FORWARDED_HOST is provided.
7177
request = HttpRequest()
7278
request.META = {
73-
u'HTTP_X_FORWARDED_HOST': u'forward.com',
74-
u'HTTP_HOST': u'example.com',
75-
u'SERVER_NAME': u'internal.com',
76-
u'SERVER_PORT': 80,
79+
'HTTP_X_FORWARDED_HOST': 'forward.com',
80+
'HTTP_HOST': 'example.com',
81+
'SERVER_NAME': 'internal.com',
82+
'SERVER_PORT': 80,
7783
}
7884
# X_FORWARDED_HOST is ignored.
7985
self.assertEqual(request.get_host(), 'example.com')
8086

8187
# Check if X_FORWARDED_HOST isn't provided.
8288
request = HttpRequest()
8389
request.META = {
84-
u'HTTP_HOST': u'example.com',
85-
u'SERVER_NAME': u'internal.com',
86-
u'SERVER_PORT': 80,
90+
'HTTP_HOST': 'example.com',
91+
'SERVER_NAME': 'internal.com',
92+
'SERVER_PORT': 80,
8793
}
8894
self.assertEqual(request.get_host(), 'example.com')
8995

9096
# Check if HTTP_HOST isn't provided.
9197
request = HttpRequest()
9298
request.META = {
93-
u'SERVER_NAME': u'internal.com',
94-
u'SERVER_PORT': 80,
99+
'SERVER_NAME': 'internal.com',
100+
'SERVER_PORT': 80,
95101
}
96102
self.assertEqual(request.get_host(), 'internal.com')
97103

98104
# Check if HTTP_HOST isn't provided, and we're on a nonstandard port
99105
request = HttpRequest()
100106
request.META = {
101-
u'SERVER_NAME': u'internal.com',
102-
u'SERVER_PORT': 8042,
107+
'SERVER_NAME': 'internal.com',
108+
'SERVER_PORT': 8042,
103109
}
104110
self.assertEqual(request.get_host(), 'internal.com:8042')
105111

@@ -112,6 +118,9 @@ def test_http_get_host(self):
112118
'[2001:19f0:feee::dead:beef:cafe]',
113119
'[2001:19f0:feee::dead:beef:cafe]:8080',
114120
'xn--4ca9at.com', # Punnycode for öäü.com
121+
'anything.multitenant.com',
122+
'multitenant.com',
123+
'insensitive.com',
115124
]
116125

117126
poisoned_hosts = [
@@ -120,6 +129,7 @@ def test_http_get_host(self):
120129
'example.com:[email protected]:80',
121130
'example.com:80/badpath',
122131
'example.com: recovermypassword.com',
132+
'other.com', # not in ALLOWED_HOSTS
123133
]
124134

125135
for host in legit_hosts:
@@ -130,55 +140,57 @@ def test_http_get_host(self):
130140
request.get_host()
131141

132142
for host in poisoned_hosts:
133-
def test_host_poisoning():
143+
def _test():
134144
request = HttpRequest()
135145
request.META = {
136146
'HTTP_HOST': host,
137147
}
138148
request.get_host()
139-
self.assertRaises(SuspiciousOperation, test_host_poisoning)
140-
149+
self.assertRaises(SuspiciousOperation, _test)
141150
finally:
142-
settings.USE_X_FORWARDED_HOST = old_USE_X_FORWARDED_HOST
151+
settings.ALLOWED_HOSTS = _old_ALLOWED_HOSTS
152+
settings.USE_X_FORWARDED_HOST = _old_USE_X_FORWARDED_HOST
143153

144154
def test_http_get_host_with_x_forwarded_host(self):
145-
old_USE_X_FORWARDED_HOST = settings.USE_X_FORWARDED_HOST
155+
_old_USE_X_FORWARDED_HOST = settings.USE_X_FORWARDED_HOST
156+
_old_ALLOWED_HOSTS = settings.ALLOWED_HOSTS
146157
try:
147158
settings.USE_X_FORWARDED_HOST = True
159+
settings.ALLOWED_HOSTS = ['*']
148160

149161
# Check if X_FORWARDED_HOST is provided.
150162
request = HttpRequest()
151163
request.META = {
152-
u'HTTP_X_FORWARDED_HOST': u'forward.com',
153-
u'HTTP_HOST': u'example.com',
154-
u'SERVER_NAME': u'internal.com',
155-
u'SERVER_PORT': 80,
164+
'HTTP_X_FORWARDED_HOST': 'forward.com',
165+
'HTTP_HOST': 'example.com',
166+
'SERVER_NAME': 'internal.com',
167+
'SERVER_PORT': 80,
156168
}
157169
# X_FORWARDED_HOST is obeyed.
158170
self.assertEqual(request.get_host(), 'forward.com')
159171

160172
# Check if X_FORWARDED_HOST isn't provided.
161173
request = HttpRequest()
162174
request.META = {
163-
u'HTTP_HOST': u'example.com',
164-
u'SERVER_NAME': u'internal.com',
165-
u'SERVER_PORT': 80,
175+
'HTTP_HOST': 'example.com',
176+
'SERVER_NAME': 'internal.com',
177+
'SERVER_PORT': 80,
166178
}
167179
self.assertEqual(request.get_host(), 'example.com')
168180

169181
# Check if HTTP_HOST isn't provided.
170182
request = HttpRequest()
171183
request.META = {
172-
u'SERVER_NAME': u'internal.com',
173-
u'SERVER_PORT': 80,
184+
'SERVER_NAME': 'internal.com',
185+
'SERVER_PORT': 80,
174186
}
175187
self.assertEqual(request.get_host(), 'internal.com')
176188

177189
# Check if HTTP_HOST isn't provided, and we're on a nonstandard port
178190
request = HttpRequest()
179191
request.META = {
180-
u'SERVER_NAME': u'internal.com',
181-
u'SERVER_PORT': 8042,
192+
'SERVER_NAME': 'internal.com',
193+
'SERVER_PORT': 8042,
182194
}
183195
self.assertEqual(request.get_host(), 'internal.com:8042')
184196

@@ -209,16 +221,33 @@ def test_http_get_host_with_x_forwarded_host(self):
209221
request.get_host()
210222

211223
for host in poisoned_hosts:
212-
def test_host_poisoning():
224+
def _test():
213225
request = HttpRequest()
214226
request.META = {
215227
'HTTP_HOST': host,
216228
}
217229
request.get_host()
218-
self.assertRaises(SuspiciousOperation, test_host_poisoning)
230+
self.assertRaises(SuspiciousOperation, _test)
231+
finally:
232+
settings.ALLOWED_HOSTS = _old_ALLOWED_HOSTS
233+
settings.USE_X_FORWARDED_HOST = _old_USE_X_FORWARDED_HOST
234+
235+
def test_host_validation_disabled_in_debug_mode(self):
236+
"""If ALLOWED_HOSTS is empty and DEBUG is True, all hosts pass."""
237+
_old_DEBUG = settings.DEBUG
238+
_old_ALLOWED_HOSTS = settings.ALLOWED_HOSTS
239+
try:
240+
settings.DEBUG = True
241+
settings.ALLOWED_HOSTS = []
219242

243+
request = HttpRequest()
244+
request.META = {
245+
'HTTP_HOST': 'example.com',
246+
}
247+
self.assertEqual(request.get_host(), 'example.com')
220248
finally:
221-
settings.USE_X_FORWARDED_HOST = old_USE_X_FORWARDED_HOST
249+
settings.DEBUG = _old_DEBUG
250+
settings.ALLOWED_HOSTS = _old_ALLOWED_HOSTS
222251

223252
def test_near_expiration(self):
224253
"Cookie will expire when an near expiration time is provided"

0 commit comments

Comments
 (0)