Skip to content

Commit 9305c0e

Browse files
committed
Fixed a security issue related to password resets
Full disclosure and new release are forthcoming
1 parent 3e08570 commit 9305c0e

File tree

4 files changed

+44
-1
lines changed

4 files changed

+44
-1
lines changed

Diff for: django/contrib/auth/tests/urls.py

+1
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ def userpage(request):
5555
(r'^logout/next_page/$', 'django.contrib.auth.views.logout', dict(next_page='/somewhere/')),
5656
(r'^remote_user/$', remote_user_auth_view),
5757
(r'^password_reset_from_email/$', 'django.contrib.auth.views.password_reset', dict(from_email='staffmember@example.com')),
58+
(r'^admin_password_reset/$', 'django.contrib.auth.views.password_reset', dict(is_admin_site=True)),
5859
(r'^login_required/$', login_required(password_reset)),
5960
(r'^login_required_login_url/$', login_required(password_reset, login_url='/somewhere/')),
6061

Diff for: django/contrib/auth/tests/views.py

+37
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from django.contrib.sites.models import Site, RequestSite
66
from django.contrib.auth.models import User
77
from django.core import mail
8+
from django.core.exceptions import SuspiciousOperation
89
from django.core.urlresolvers import reverse, NoReverseMatch
910
from django.http import QueryDict
1011
from django.utils.encoding import force_text
@@ -103,6 +104,42 @@ def test_email_found_custom_from(self):
103104
self.assertEqual(len(mail.outbox), 1)
104105
self.assertEqual("staffmember@example.com", mail.outbox[0].from_email)
105106

107+
def test_admin_reset(self):
108+
"If the reset view is marked as being for admin, the HTTP_HOST header is used for a domain override."
109+
response = self.client.post('/admin_password_reset/',
110+
{'email': 'staffmember@example.com'},
111+
HTTP_HOST='adminsite.com'
112+
)
113+
self.assertEqual(response.status_code, 302)
114+
self.assertEqual(len(mail.outbox), 1)
115+
self.assertTrue("http://adminsite.com" in mail.outbox[0].body)
116+
self.assertEqual(settings.DEFAULT_FROM_EMAIL, mail.outbox[0].from_email)
117+
118+
def test_poisoned_http_host(self):
119+
"Poisoned HTTP_HOST headers can't be used for reset emails"
120+
# This attack is based on the way browsers handle URLs. The colon
121+
# should be used to separate the port, but if the URL contains an @,
122+
# the colon is interpreted as part of a username for login purposes,
123+
# making 'evil.com' the request domain. Since HTTP_HOST is used to
124+
# produce a meaningful reset URL, we need to be certain that the
125+
# HTTP_HOST header isn't poisoned. This is done as a check when get_host()
126+
# is invoked, but we check here as a practical consequence.
127+
with self.assertRaises(SuspiciousOperation):
128+
self.client.post('/password_reset/',
129+
{'email': 'staffmember@example.com'},
130+
HTTP_HOST='www.example:dr.frankenstein@evil.tld'
131+
)
132+
self.assertEqual(len(mail.outbox), 0)
133+
134+
def test_poisoned_http_host_admin_site(self):
135+
"Poisoned HTTP_HOST headers can't be used for reset emails on admin views"
136+
with self.assertRaises(SuspiciousOperation):
137+
self.client.post('/admin_password_reset/',
138+
{'email': 'staffmember@example.com'},
139+
HTTP_HOST='www.example:dr.frankenstein@evil.tld'
140+
)
141+
self.assertEqual(len(mail.outbox), 0)
142+
106143
def _test_confirm_start(self):
107144
# Start by creating the email
108145
response = self.client.post('/password_reset/', {'email': 'staffmember@example.com'})

Diff for: django/contrib/auth/views.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,7 @@ def password_reset(request, is_admin_site=False,
163163
'request': request,
164164
}
165165
if is_admin_site:
166-
opts = dict(opts, domain_override=request.META['HTTP_HOST'])
166+
opts = dict(opts, domain_override=request.get_host())
167167
form.save(**opts)
168168
return HttpResponseRedirect(post_reset_redirect)
169169
else:

Diff for: django/http/__init__.py

+5
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,11 @@ def get_host(self):
180180
server_port = str(self.META['SERVER_PORT'])
181181
if server_port != ('443' if self.is_secure() else '80'):
182182
host = '%s:%s' % (host, server_port)
183+
184+
# Disallow potentially poisoned hostnames.
185+
if set(';/?@&=+$,').intersection(host):
186+
raise SuspiciousOperation('Invalid HTTP_HOST header: %s' % host)
187+
183188
return host
184189

185190
def get_full_path(self):

0 commit comments

Comments
 (0)