CVE-2012-4520: Improper Input Validation (CWE-20)
value: The django.http.HttpRequest.get_host function in Django 1.3.x before 1.3.4 and 1.4.x before 1.4.2 allows remote attackers to generate and display arbitrary URLs via crafted username and password Host header values.
CWE description: The product receives input or data, but it does
        not validate or incorrectly validates that the input has the
        properties that are required to process the data safely and
        correctly.
Published: 2012-11-18T23:55Z
Repository: https://github.com/django/django
Language: Python
Files changed: django/contrib/auth/tests/urls.py, django/contrib/auth/tests/views.py, django/contrib/auth/views.py, django/http/__init__.py
Commit date: 2012-10-17 14:36:41 -0700
Commit message: Fixed a security issue related to password resets

Full disclosure and new release are forthcoming
Diff: diff --git a/django/contrib/auth/tests/urls.py b/django/contrib/auth/tests/urls.py
index 8f9e848aa..4b498ceaf 100644
--- a/django/contrib/auth/tests/urls.py
+++ b/django/contrib/auth/tests/urls.py
@@ -53,10 +53,11 @@ def userpage(request):
 urlpatterns = urlpatterns + patterns('',
     (r'^logout/custom_query/$', 'django.contrib.auth.views.logout', dict(redirect_field_name='follow')),
     (r'^logout/next_page/$', 'django.contrib.auth.views.logout', dict(next_page='/somewhere/')),
     (r'^remote_user/$', remote_user_auth_view),
     (r'^password_reset_from_email/$', 'django.contrib.auth.views.password_reset', dict(from_email='staffmember@example.com')),
+    (r'^admin_password_reset/$', 'django.contrib.auth.views.password_reset', dict(is_admin_site=True)),
     (r'^login_required/$', login_required(password_reset)),
     (r'^login_required_login_url/$', login_required(password_reset, login_url='/somewhere/')),
 
     (r'^auth_processor_no_attr_access/$', auth_processor_no_attr_access),
     (r'^auth_processor_attr_access/$', auth_processor_attr_access),
diff --git a/django/contrib/auth/tests/views.py b/django/contrib/auth/tests/views.py
index 5727dc289..bb17576d3 100644
--- a/django/contrib/auth/tests/views.py
+++ b/django/contrib/auth/tests/views.py
@@ -3,10 +3,11 @@ import re
 
 from django.conf import global_settings, settings
 from django.contrib.sites.models import Site, RequestSite
 from django.contrib.auth.models import User
 from django.core import mail
+from django.core.exceptions import SuspiciousOperation
 from django.core.urlresolvers import reverse, NoReverseMatch
 from django.http import QueryDict
 from django.utils.encoding import force_text
 from django.utils.html import escape
 from django.utils.http import urlquote
@@ -101,10 +102,46 @@ class PasswordResetTest(AuthViewsTestCase):
         response = self.client.post('/password_reset_from_email/', {'email': 'staffmember@example.com'})
         self.assertEqual(response.status_code, 302)
         self.assertEqual(len(mail.outbox), 1)
         self.assertEqual("staffmember@example.com", mail.outbox[0].from_email)
 
+    def test_admin_reset(self):
+        "If the reset view is marked as being for admin, the HTTP_HOST header is used for a domain override."
+        response = self.client.post('/admin_password_reset/',
+            {'email': 'staffmember@example.com'},
+            HTTP_HOST='adminsite.com'
+        )
+        self.assertEqual(response.status_code, 302)
+        self.assertEqual(len(mail.outbox), 1)
+        self.assertTrue("http://adminsite.com" in mail.outbox[0].body)
+        self.assertEqual(settings.DEFAULT_FROM_EMAIL, mail.outbox[0].from_email)
+
+    def test_poisoned_http_host(self):
+        "Poisoned HTTP_HOST headers can't be used for reset emails"
+        # This attack is based on the way browsers handle URLs. The colon
+        # should be used to separate the port, but if the URL contains an @,
+        # the colon is interpreted as part of a username for login purposes,
+        # making 'evil.com' the request domain. Since HTTP_HOST is used to
+        # produce a meaningful reset URL, we need to be certain that the
+        # HTTP_HOST header isn't poisoned. This is done as a check when get_host()
+        # is invoked, but we check here as a practical consequence.
+        with self.assertRaises(SuspiciousOperation):
+            self.client.post('/password_reset/',
+                {'email': 'staffmember@example.com'},
+                HTTP_HOST='www.example:dr.frankenstein@evil.tld'
+            )
+        self.assertEqual(len(mail.outbox), 0)
+
+    def test_poisoned_http_host_admin_site(self):
+        "Poisoned HTTP_HOST headers can't be used for reset emails on admin views"
+        with self.assertRaises(SuspiciousOperation):
+            self.client.post('/admin_password_reset/',
+                {'email': 'staffmember@example.com'},
+                HTTP_HOST='www.example:dr.frankenstein@evil.tld'
+            )
+        self.assertEqual(len(mail.outbox), 0)
+
     def _test_confirm_start(self):
         # Start by creating the email
         response = self.client.post('/password_reset/', {'email': 'staffmember@example.com'})
         self.assertEqual(response.status_code, 302)
         self.assertEqual(len(mail.outbox), 1)
diff --git a/django/contrib/auth/views.py b/django/contrib/auth/views.py
index 747b5c099..d27e2f5ab 100644
--- a/django/contrib/auth/views.py
+++ b/django/contrib/auth/views.py
@@ -161,11 +161,11 @@ def password_reset(request, is_admin_site=False,
                 'email_template_name': email_template_name,
                 'subject_template_name': subject_template_name,
                 'request': request,
             }
             if is_admin_site:
-                opts = dict(opts, domain_override=request.META['HTTP_HOST'])
+                opts = dict(opts, domain_override=request.get_host())
             form.save(**opts)
             return HttpResponseRedirect(post_reset_redirect)
     else:
         form = password_reset_form()
     context = {
diff --git a/django/http/__init__.py b/django/http/__init__.py
index ecb39129a..b385b450e 100644
--- a/django/http/__init__.py
+++ b/django/http/__init__.py
@@ -178,10 +178,15 @@ class HttpRequest(object):
             # Reconstruct the host using the algorithm from PEP 333.
             host = self.META['SERVER_NAME']
             server_port = str(self.META['SERVER_PORT'])
             if server_port != ('443' if self.is_secure() else '80'):
                 host = '%s:%s' % (host, server_port)
+
+        # Disallow potentially poisoned hostnames.
+        if set(';/?@&=+$,').intersection(host):
+            raise SuspiciousOperation('Invalid HTTP_HOST header: %s' % host)
+
         return host
 
     def get_full_path(self):
         # RFC 3986 requires query string arguments to be in the ASCII range.
         # Rather than crash if this doesn't happen, we encode defensively.

Vulnerable code:                 opts = dict(opts, domain_override=request.META['HTTP_HOST'])
Fixed code:     (r'^admin_password_reset/$', 'django.contrib.auth.views.password_reset', dict(is_admin_site=True)),
from django.core.exceptions import SuspiciousOperation
    def test_admin_reset(self):
        "If the reset view is marked as being for admin, the HTTP_HOST header is used for a domain override."
        response = self.client.post('/admin_password_reset/',
            {'email': 'staffmember@example.com'},
            HTTP_HOST='adminsite.com'
        )
        self.assertEqual(response.status_code, 302)
        self.assertEqual(len(mail.outbox), 1)
        self.assertTrue("http://adminsite.com" in mail.outbox[0].body)
        self.assertEqual(settings.DEFAULT_FROM_EMAIL, mail.outbox[0].from_email)

    def test_poisoned_http_host(self):
        "Poisoned HTTP_HOST headers can't be used for reset emails"
        # This attack is based on the way browsers handle URLs. The colon
        # should be used to separate the port, but if the URL contains an @,
        # the colon is interpreted as part of a username for login purposes,
        # making 'evil.com' the request domain. Since HTTP_HOST is used to
        # produce a meaningful reset URL, we need to be certain that the
        # HTTP_HOST header isn't poisoned. This is done as a check when get_host()
        # is invoked, but we check here as a practical consequence.
        with self.assertRaises(SuspiciousOperation):
            self.client.post('/password_reset/',
                {'email': 'staffmember@example.com'},
                HTTP_HOST='www.example:dr.frankenstein@evil.tld'
            )
        self.assertEqual(len(mail.outbox), 0)

    def test_poisoned_http_host_admin_site(self):
        "Poisoned HTTP_HOST headers can't be used for reset emails on admin views"
        with self.assertRaises(SuspiciousOperation):
            self.client.post('/admin_password_reset/',
                {'email': 'staffmember@example.com'},
                HTTP_HOST='www.example:dr.frankenstein@evil.tld'
            )
        self.assertEqual(len(mail.outbox), 0)

                opts = dict(opts, domain_override=request.get_host())

        # Disallow potentially poisoned hostnames.
        if set(';/?@&=+$,').intersection(host):
            raise SuspiciousOperation('Invalid HTTP_HOST header: %s' % host)