Skip to content
This repository

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
Browse code

Fixed a security issue related to password resets

Full disclosure and new release are forthcoming
commit 9305c0e12d43c4df999c3301a1f0c742264a657e 1 parent 3e08570
Preston Holmes authored October 17, 2012
1  django/contrib/auth/tests/urls.py
@@ -55,6 +55,7 @@ def userpage(request):
55 55
     (r'^logout/next_page/$', 'django.contrib.auth.views.logout', dict(next_page='/somewhere/')),
56 56
     (r'^remote_user/$', remote_user_auth_view),
57 57
     (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)),
58 59
     (r'^login_required/$', login_required(password_reset)),
59 60
     (r'^login_required_login_url/$', login_required(password_reset, login_url='/somewhere/')),
60 61
 
37  django/contrib/auth/tests/views.py
@@ -5,6 +5,7 @@
5 5
 from django.contrib.sites.models import Site, RequestSite
6 6
 from django.contrib.auth.models import User
7 7
 from django.core import mail
  8
+from django.core.exceptions import SuspiciousOperation
8 9
 from django.core.urlresolvers import reverse, NoReverseMatch
9 10
 from django.http import QueryDict
10 11
 from django.utils.encoding import force_text
@@ -103,6 +104,42 @@ def test_email_found_custom_from(self):
103 104
         self.assertEqual(len(mail.outbox), 1)
104 105
         self.assertEqual("staffmember@example.com", mail.outbox[0].from_email)
105 106
 
  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
+
106 143
     def _test_confirm_start(self):
107 144
         # Start by creating the email
108 145
         response = self.client.post('/password_reset/', {'email': 'staffmember@example.com'})
2  django/contrib/auth/views.py
@@ -163,7 +163,7 @@ def password_reset(request, is_admin_site=False,
163 163
                 'request': request,
164 164
             }
165 165
             if is_admin_site:
166  
-                opts = dict(opts, domain_override=request.META['HTTP_HOST'])
  166
+                opts = dict(opts, domain_override=request.get_host())
167 167
             form.save(**opts)
168 168
             return HttpResponseRedirect(post_reset_redirect)
169 169
     else:
5  django/http/__init__.py
@@ -180,6 +180,11 @@ def get_host(self):
180 180
             server_port = str(self.META['SERVER_PORT'])
181 181
             if server_port != ('443' if self.is_secure() else '80'):
182 182
                 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
+
183 188
         return host
184 189
 
185 190
     def get_full_path(self):

0 notes on commit 9305c0e

Victor Noagbodji

This statement makes the test fail, because the view won't raise that exception directly since Django will try to handle the display of server errors by default. A quick fix would be to set DEBUG_PROPAGATE_EXCEPTIONS = True in your test settings. But I guess assertContains(response, 'SuspiciousOperation') could work here.

Victor Noagbodji

If my note above is valid then this one is concerned too.

Please sign in to comment.
Something went wrong with that request. Please try again.