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

backport from master
  • Loading branch information...
commit b45c377f8f488955e0c7069cad3f3dd21910b071 1 parent c718b4a
Preston Holmes authored October 17, 2012
1  django/contrib/auth/tests/urls.py
@@ -19,6 +19,7 @@ def remote_user_auth_view(request):
19 19
     (r'^logout/next_page/$', 'django.contrib.auth.views.logout', dict(next_page='/somewhere/')),
20 20
     (r'^remote_user/$', remote_user_auth_view),
21 21
     (r'^password_reset_from_email/$', 'django.contrib.auth.views.password_reset', dict(from_email='staffmember@example.com')),
  22
+    (r'^admin_password_reset/$', 'django.contrib.auth.views.password_reset', dict(is_admin_site=True)),
22 23
     (r'^login_required/$', login_required(password_reset)),
23 24
     (r'^login_required_login_url/$', login_required(password_reset, login_url='/somewhere/')),
24 25
 )
39  django/contrib/auth/tests/views.py
@@ -9,6 +9,7 @@
9 9
 from django.contrib.auth.models import User
10 10
 from django.test import TestCase
11 11
 from django.core import mail
  12
+from django.core.exceptions import SuspiciousOperation
12 13
 from django.core.urlresolvers import reverse
13 14
 from django.http import QueryDict
14 15
 
@@ -69,6 +70,44 @@ def test_email_found_custom_from(self):
69 70
         self.assertEqual(len(mail.outbox), 1)
70 71
         self.assertEqual("staffmember@example.com", mail.outbox[0].from_email)
71 72
 
  73
+    def test_admin_reset(self):
  74
+        "If the reset view is marked as being for admin, the HTTP_HOST header is used for a domain override."
  75
+        response = self.client.post('/admin_password_reset/',
  76
+            {'email': 'staffmember@example.com'},
  77
+            HTTP_HOST='adminsite.com'
  78
+        )
  79
+        self.assertEqual(response.status_code, 302)
  80
+        self.assertEqual(len(mail.outbox), 1)
  81
+        self.assertTrue("http://adminsite.com" in mail.outbox[0].body)
  82
+        self.assertEqual(settings.DEFAULT_FROM_EMAIL, mail.outbox[0].from_email)
  83
+
  84
+    def test_poisoned_http_host(self):
  85
+        "Poisoned HTTP_HOST headers can't be used for reset emails"
  86
+        # This attack is based on the way browsers handle URLs. The colon
  87
+        # should be used to separate the port, but if the URL contains an @,
  88
+        # the colon is interpreted as part of a username for login purposes,
  89
+        # making 'evil.com' the request domain. Since HTTP_HOST is used to
  90
+        # produce a meaningful reset URL, we need to be certain that the
  91
+        # HTTP_HOST header isn't poisoned. This is done as a check when get_host()
  92
+        # is invoked, but we check here as a practical consequence.
  93
+        def test_host_poisoning():
  94
+            self.client.post('/password_reset/',
  95
+                {'email': 'staffmember@example.com'},
  96
+                HTTP_HOST='www.example:dr.frankenstein@evil.tld'
  97
+            )
  98
+        self.assertRaises(SuspiciousOperation, test_host_poisoning)
  99
+        self.assertEqual(len(mail.outbox), 0)
  100
+
  101
+    def test_poisoned_http_host_admin_site(self):
  102
+        "Poisoned HTTP_HOST headers can't be used for reset emails on admin views"
  103
+        def test_host_poisoning():
  104
+            self.client.post('/admin_password_reset/',
  105
+                {'email': 'staffmember@example.com'},
  106
+                HTTP_HOST='www.example:dr.frankenstein@evil.tld'
  107
+            )
  108
+        self.assertRaises(SuspiciousOperation, test_host_poisoning)
  109
+        self.assertEqual(len(mail.outbox), 0)
  110
+
72 111
     def _test_confirm_start(self):
73 112
         # Start by creating the email
74 113
         response = self.client.post('/password_reset/', {'email': 'staffmember@example.com'})
2  django/contrib/auth/views.py
@@ -151,7 +151,7 @@ def password_reset(request, is_admin_site=False,
151 151
                 'request': request,
152 152
             }
153 153
             if is_admin_site:
154  
-                opts = dict(opts, domain_override=request.META['HTTP_HOST'])
  154
+                opts = dict(opts, domain_override=request.get_host())
155 155
             form.save(**opts)
156 156
             return HttpResponseRedirect(post_reset_redirect)
157 157
     else:
5  django/http/__init__.py
@@ -165,6 +165,11 @@ def get_host(self):
165 165
             server_port = str(self.META['SERVER_PORT'])
166 166
             if server_port != (self.is_secure() and '443' or '80'):
167 167
                 host = '%s:%s' % (host, server_port)
  168
+
  169
+        # Disallow potentially poisoned hostnames.
  170
+        if set(';/?@&=+$,').intersection(host):
  171
+            raise SuspiciousOperation('Invalid HTTP_HOST header: %s' % host)
  172
+
168 173
         return host
169 174
 
170 175
     def get_full_path(self):

0 notes on commit b45c377

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