Skip to content
This repository

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
Browse code

Fixed #14445 - Use HMAC and constant-time comparison functions where …

…needed.

All adhoc MAC applications have been updated to use HMAC, using SHA1 to
generate unique keys for each application based on the SECRET_KEY, which is
common practice for this situation. In all cases, backwards compatibility
with existing hashes has been maintained, aiming to phase this out as per
the normal deprecation process. In this way, under most normal
circumstances the old hashes will have expired (e.g. by session expiration
etc.) before they become invalid.

In the case of the messages framework and the cookie backend, which was
already using HMAC, there is the possibility of a backwards incompatibility
if the SECRET_KEY is shorter than the default 50 bytes, but the low
likelihood and low impact meant compatibility code was not worth it.

All known instances where tokens/hashes were compared using simple string
equality, which could potentially open timing based attacks, have also been
fixed using a constant-time comparison function.

There are no known practical attacks against the existing implementations,
so these security improvements will not be backported.

git-svn-id: http://code.djangoproject.com/svn/django/trunk@14218 bcc190cf-cafb-0310-a4f2-bffc1f526a37
  • Loading branch information...
commit 45c7f427ce830dd1b2f636fb9c244fda9201cadb 1 parent 36f2f7e
Luke Plant authored October 14, 2010
22  django/contrib/auth/tests/tokens.py
@@ -50,3 +50,25 @@ def _today(self):
50 50
 
51 51
         p2 = Mocked(date.today() + timedelta(settings.PASSWORD_RESET_TIMEOUT_DAYS + 1))
52 52
         self.assertFalse(p2.check_token(user, tk1))
  53
+
  54
+    def test_django12_hash(self):
  55
+        """
  56
+        Ensure we can use the hashes generated by Django 1.2
  57
+        """
  58
+        # Hard code in the Django 1.2 algorithm (not the result, as it is time
  59
+        # dependent)
  60
+        def _make_token(user):
  61
+            from django.utils.hashcompat import sha_constructor
  62
+            from django.utils.http import int_to_base36
  63
+
  64
+            timestamp = (date.today() - date(2001,1,1)).days
  65
+            ts_b36 = int_to_base36(timestamp)
  66
+            hash = sha_constructor(settings.SECRET_KEY + unicode(user.id) +
  67
+                                   user.password + user.last_login.strftime('%Y-%m-%d %H:%M:%S') +
  68
+                                   unicode(timestamp)).hexdigest()[::2]
  69
+            return "%s-%s" % (ts_b36, hash)
  70
+
  71
+        user = User.objects.create_user('tokentestuser', 'test2@example.com', 'testpw')
  72
+        p0 = PasswordResetTokenGenerator()
  73
+        tk1 = _make_token(user)
  74
+        self.assertTrue(p0.check_token(user, tk1))
22  django/contrib/auth/tokens.py
... ...
@@ -1,6 +1,9 @@
1 1
 from datetime import date
  2
+
2 3
 from django.conf import settings
  4
+from django.utils.hashcompat import sha_constructor
3 5
 from django.utils.http import int_to_base36, base36_to_int
  6
+from django.utils.crypto import constant_time_compare, salted_hmac
4 7
 
5 8
 class PasswordResetTokenGenerator(object):
6 9
     """
@@ -30,8 +33,12 @@ def check_token(self, user, token):
30 33
             return False
31 34
 
32 35
         # Check that the timestamp/uid has not been tampered with
33  
-        if self._make_token_with_timestamp(user, ts) != token:
34  
-            return False
  36
+        if not constant_time_compare(self._make_token_with_timestamp(user, ts), token):
  37
+            # Fallback to Django 1.2 method for compatibility.
  38
+            # PendingDeprecationWarning <- here to remind us to remove this in
  39
+            # Django 1.5
  40
+            if not constant_time_compare(self._make_token_with_timestamp_old(user, ts), token):
  41
+                return False
35 42
 
36 43
         # Check the timestamp is within limit
37 44
         if (self._num_days(self._today()) - ts) > settings.PASSWORD_RESET_TIMEOUT_DAYS:
@@ -50,7 +57,16 @@ def _make_token_with_timestamp(self, user, timestamp):
50 57
         # last_login will also change), we produce a hash that will be
51 58
         # invalid as soon as it is used.
52 59
         # We limit the hash to 20 chars to keep URL short
53  
-        from django.utils.hashcompat import sha_constructor
  60
+        key_salt = "django.contrib.auth.tokens.PasswordResetTokenGenerator"
  61
+        value = unicode(user.id) + \
  62
+            user.password + user.last_login.strftime('%Y-%m-%d %H:%M:%S') + \
  63
+            unicode(timestamp)
  64
+        hash = salted_hmac(key_salt, value).hexdigest()[::2]
  65
+        return "%s-%s" % (ts_b36, hash)
  66
+
  67
+    def _make_token_with_timestamp_old(self, user, timestamp):
  68
+        # The Django 1.2 method
  69
+        ts_b36 = int_to_base36(timestamp)
54 70
         hash = sha_constructor(settings.SECRET_KEY + unicode(user.id) +
55 71
                                user.password + user.last_login.strftime('%Y-%m-%d %H:%M:%S') +
56 72
                                unicode(timestamp)).hexdigest()[::2]
20  django/contrib/comments/forms.py
@@ -6,6 +6,7 @@
6 6
 from django.conf import settings
7 7
 from django.contrib.contenttypes.models import ContentType
8 8
 from models import Comment
  9
+from django.utils.crypto import salted_hmac, constant_time_compare
9 10
 from django.utils.encoding import force_unicode
10 11
 from django.utils.hashcompat import sha_constructor
11 12
 from django.utils.text import get_text_list
@@ -46,8 +47,13 @@ def clean_security_hash(self):
46 47
         }
47 48
         expected_hash = self.generate_security_hash(**security_hash_dict)
48 49
         actual_hash = self.cleaned_data["security_hash"]
49  
-        if expected_hash != actual_hash:
50  
-            raise forms.ValidationError("Security hash check failed.")
  50
+        if not constant_time_compare(expected_hash, actual_hash):
  51
+            # Fallback to Django 1.2 method for compatibility
  52
+            # PendingDeprecationWarning <- here to remind us to remove this
  53
+            # fallback in Django 1.5
  54
+            expected_hash_old = self._generate_security_hash_old(**security_hash_dict)
  55
+            if not constant_time_compare(expected_hash_old, actual_hash):
  56
+                raise forms.ValidationError("Security hash check failed.")
51 57
         return actual_hash
52 58
 
53 59
     def clean_timestamp(self):
@@ -82,7 +88,17 @@ def initial_security_hash(self, timestamp):
82 88
         return self.generate_security_hash(**initial_security_dict)
83 89
 
84 90
     def generate_security_hash(self, content_type, object_pk, timestamp):
  91
+        """
  92
+        Generate a HMAC security hash from the provided info.
  93
+        """
  94
+        info = (content_type, object_pk, timestamp)
  95
+        key_salt = "django.contrib.forms.CommentSecurityForm"
  96
+        value = "-".join(info)
  97
+        return salted_hmac(key_salt, value).hexdigest()
  98
+
  99
+    def _generate_security_hash_old(self, content_type, object_pk, timestamp):
85 100
         """Generate a (SHA1) security hash from the provided info."""
  101
+        # Django 1.2 compatibility
86 102
         info = (content_type, object_pk, timestamp, settings.SECRET_KEY)
87 103
         return sha_constructor("".join(info)).hexdigest()
88 104
 
25  django/contrib/formtools/preview.py
@@ -9,6 +9,7 @@
9 9
 from django.shortcuts import render_to_response
10 10
 from django.template.context import RequestContext
11 11
 from django.utils.hashcompat import md5_constructor
  12
+from django.utils.crypto import constant_time_compare
12 13
 from django.contrib.formtools.utils import security_hash
13 14
 
14 15
 AUTO_ID = 'formtools_%s' # Each form here uses this as its auto_id parameter.
@@ -67,11 +68,33 @@ def preview_post(self, request):
67 68
         else:
68 69
             return render_to_response(self.form_template, context, context_instance=RequestContext(request))
69 70
 
  71
+    def _check_security_hash(self, token, request, form):
  72
+        expected = self.security_hash(request, form)
  73
+        if constant_time_compare(token, expected):
  74
+            return True
  75
+        else:
  76
+            # Fall back to Django 1.2 method, for compatibility with forms that
  77
+            # are in the middle of being used when the upgrade occurs. However,
  78
+            # we don't want to do this fallback if a subclass has provided their
  79
+            # own security_hash method - because they might have implemented a
  80
+            # more secure method, and this would punch a hole in that.
  81
+
  82
+            # PendingDeprecationWarning <- left here to remind us that this
  83
+            # compatibility fallback should be removed in Django 1.5
  84
+            FormPreview_expected = FormPreview.security_hash(self, request, form)
  85
+            if expected == FormPreview_expected:
  86
+                # They didn't override security_hash, do the fallback:
  87
+                old_expected = security_hash(request, form)
  88
+                return constant_time_compare(token, old_expected)
  89
+            else:
  90
+                return False
  91
+
70 92
     def post_post(self, request):
71 93
         "Validates the POST data. If valid, calls done(). Else, redisplays form."
72 94
         f = self.form(request.POST, auto_id=AUTO_ID)
73 95
         if f.is_valid():
74  
-            if self.security_hash(request, f) != request.POST.get(self.unused_name('hash')):
  96
+            if not self._check_security_hash(request.POST.get(self.unused_name('hash'), ''),
  97
+                                             request, f):
75 98
                 return self.failed_hash(request) # Security hash failed.
76 99
             return self.done(request, f.cleaned_data)
77 100
         else:
10  django/contrib/formtools/test_urls.py
... ...
@@ -1,10 +0,0 @@
1  
-"""
2  
-This is a URLconf to be loaded by tests.py. Add any URLs needed for tests only.
3  
-"""
4  
-
5  
-from django.conf.urls.defaults import *
6  
-from django.contrib.formtools.tests import *
7  
-
8  
-urlpatterns = patterns('',
9  
-                       (r'^test1/', TestFormPreview(TestForm)),
10  
-                      )
183  django/contrib/formtools/tests.py → django/contrib/formtools/tests/__init__.py
... ...
@@ -1,23 +1,37 @@
  1
+import os
  2
+
1 3
 from django import forms
2 4
 from django import http
  5
+from django.conf import settings
3 6
 from django.contrib.formtools import preview, wizard, utils
4 7
 from django.test import TestCase
5 8
 from django.utils import unittest
6 9
 
7 10
 success_string = "Done was called!"
8 11
 
  12
+
9 13
 class TestFormPreview(preview.FormPreview):
10 14
 
11 15
     def done(self, request, cleaned_data):
12 16
         return http.HttpResponse(success_string)
13 17
 
  18
+
14 19
 class TestForm(forms.Form):
15 20
     field1 = forms.CharField()
16 21
     field1_ = forms.CharField()
17 22
     bool1 = forms.BooleanField(required=False)
18 23
 
  24
+
  25
+class UserSecuredFormPreview(TestFormPreview):
  26
+    """
  27
+    FormPreview with a custum security_hash method
  28
+    """
  29
+    def security_hash(self, request, form):
  30
+        return "123"
  31
+
  32
+
19 33
 class PreviewTests(TestCase):
20  
-    urls = 'django.contrib.formtools.test_urls'
  34
+    urls = 'django.contrib.formtools.tests.urls'
21 35
 
22 36
     def setUp(self):
23 37
         # Create a FormPreview instance to share between tests
@@ -102,6 +116,39 @@ def test_bool_submit(self):
102 116
         response = self.client.post('/test1/', self.test_data)
103 117
         self.assertEqual(response.content, success_string)
104 118
 
  119
+    def test_form_submit_django12_hash(self):
  120
+        """
  121
+        Test contrib.formtools.preview form submittal, using the hash function
  122
+        used in Django 1.2
  123
+        """
  124
+        # Pass strings for form submittal and add stage variable to
  125
+        # show we previously saw first stage of the form.
  126
+        self.test_data.update({'stage':2})
  127
+        response = self.client.post('/test1/', self.test_data)
  128
+        self.failIfEqual(response.content, success_string)
  129
+        hash = utils.security_hash(None, TestForm(self.test_data))
  130
+        self.test_data.update({'hash': hash})
  131
+        response = self.client.post('/test1/', self.test_data)
  132
+        self.assertEqual(response.content, success_string)
  133
+
  134
+
  135
+    def test_form_submit_django12_hash_custom_hash(self):
  136
+        """
  137
+        Test contrib.formtools.preview form submittal, using the hash function
  138
+        used in Django 1.2 and a custom security_hash method.
  139
+        """
  140
+        # Pass strings for form submittal and add stage variable to
  141
+        # show we previously saw first stage of the form.
  142
+        self.test_data.update({'stage':2})
  143
+        response = self.client.post('/test2/', self.test_data)
  144
+        self.assertEqual(response.status_code, 200)
  145
+        self.failIfEqual(response.content, success_string)
  146
+        hash = utils.security_hash(None, TestForm(self.test_data))
  147
+        self.test_data.update({'hash': hash})
  148
+        response = self.client.post('/test2/', self.test_data)
  149
+        self.failIfEqual(response.content, success_string)
  150
+
  151
+
105 152
 class SecurityHashTests(unittest.TestCase):
106 153
 
107 154
     def test_textfield_hash(self):
@@ -127,10 +174,41 @@ def test_empty_permitted(self):
127 174
         hash2 = utils.security_hash(None, f2)
128 175
         self.assertEqual(hash1, hash2)
129 176
 
  177
+
  178
+class FormHmacTests(unittest.TestCase):
  179
+    """
  180
+    Same as SecurityHashTests, but with form_hmac
  181
+    """
  182
+
  183
+    def test_textfield_hash(self):
  184
+        """
  185
+        Regression test for #10034: the hash generation function should ignore
  186
+        leading/trailing whitespace so as to be friendly to broken browsers that
  187
+        submit it (usually in textareas).
  188
+        """
  189
+        f1 = HashTestForm({'name': 'joe', 'bio': 'Nothing notable.'})
  190
+        f2 = HashTestForm({'name': '  joe', 'bio': 'Nothing notable.  '})
  191
+        hash1 = utils.form_hmac(f1)
  192
+        hash2 = utils.form_hmac(f2)
  193
+        self.assertEqual(hash1, hash2)
  194
+
  195
+    def test_empty_permitted(self):
  196
+        """
  197
+        Regression test for #10643: the security hash should allow forms with
  198
+        empty_permitted = True, or forms where data has not changed.
  199
+        """
  200
+        f1 = HashTestBlankForm({})
  201
+        f2 = HashTestForm({}, empty_permitted=True)
  202
+        hash1 = utils.form_hmac(f1)
  203
+        hash2 = utils.form_hmac(f2)
  204
+        self.assertEqual(hash1, hash2)
  205
+
  206
+
130 207
 class HashTestForm(forms.Form):
131 208
     name = forms.CharField()
132 209
     bio = forms.CharField()
133 210
 
  211
+
134 212
 class HashTestBlankForm(forms.Form):
135 213
     name = forms.CharField(required=False)
136 214
     bio = forms.CharField(required=False)
@@ -139,20 +217,38 @@ class HashTestBlankForm(forms.Form):
139 217
 # FormWizard tests
140 218
 #
141 219
 
  220
+
142 221
 class WizardPageOneForm(forms.Form):
143 222
     field = forms.CharField()
144 223
 
  224
+
145 225
 class WizardPageTwoForm(forms.Form):
146 226
     field = forms.CharField()
147 227
 
  228
+
  229
+class WizardPageThreeForm(forms.Form):
  230
+    field = forms.CharField()
  231
+
  232
+
148 233
 class WizardClass(wizard.FormWizard):
149  
-    def render_template(self, *args, **kw):
150  
-        return http.HttpResponse("")
  234
+
  235
+    def get_template(self, step):
  236
+        return 'formwizard/wizard.html'
151 237
 
152 238
     def done(self, request, cleaned_data):
153 239
         return http.HttpResponse(success_string)
154 240
 
  241
+
  242
+class UserSecuredWizardClass(WizardClass):
  243
+    """
  244
+    Wizard with a custum security_hash method
  245
+    """
  246
+    def security_hash(self, request, form):
  247
+        return "123"
  248
+
  249
+
155 250
 class DummyRequest(http.HttpRequest):
  251
+
156 252
     def __init__(self, POST=None):
157 253
         super(DummyRequest, self).__init__()
158 254
         self.method = POST and "POST" or "GET"
@@ -160,22 +256,87 @@ def __init__(self, POST=None):
160 256
             self.POST.update(POST)
161 257
         self._dont_enforce_csrf_checks = True
162 258
 
  259
+
163 260
 class WizardTests(TestCase):
  261
+    urls = 'django.contrib.formtools.tests.urls'
  262
+
  263
+    def setUp(self):
  264
+        self.old_TEMPLATE_DIRS = settings.TEMPLATE_DIRS
  265
+        settings.TEMPLATE_DIRS = (
  266
+            os.path.join(
  267
+                os.path.dirname(__file__),
  268
+                'templates'
  269
+            ),
  270
+        )
  271
+        # Use a known SECRET_KEY to make security_hash tests deterministic
  272
+        self.old_SECRET_KEY = settings.SECRET_KEY
  273
+        settings.SECRET_KEY = "123"
  274
+
  275
+    def tearDown(self):
  276
+        settings.TEMPLATE_DIRS = self.old_TEMPLATE_DIRS
  277
+        settings.SECRET_KEY = self.old_SECRET_KEY
  278
+
164 279
     def test_step_starts_at_zero(self):
165 280
         """
166 281
         step should be zero for the first form
167 282
         """
168  
-        wizard = WizardClass([WizardPageOneForm, WizardPageTwoForm])
169  
-        request = DummyRequest()
170  
-        wizard(request)
171  
-        self.assertEquals(0, wizard.step)
  283
+        response = self.client.get('/wizard/')
  284
+        self.assertEquals(0, response.context['step0'])
172 285
 
173 286
     def test_step_increments(self):
174 287
         """
175 288
         step should be incremented when we go to the next page
176 289
         """
177  
-        wizard = WizardClass([WizardPageOneForm, WizardPageTwoForm])
178  
-        request = DummyRequest(POST={"0-field":"test", "wizard_step":"0"})
179  
-        response = wizard(request)
180  
-        self.assertEquals(1, wizard.step)
  290
+        response = self.client.post('/wizard/', {"0-field":"test", "wizard_step":"0"})
  291
+        self.assertEquals(1, response.context['step0'])
181 292
 
  293
+    def test_bad_hash(self):
  294
+        """
  295
+        Form should not advance if the hash is missing or bad
  296
+        """
  297
+        response = self.client.post('/wizard/',
  298
+                                    {"0-field":"test",
  299
+                                     "1-field":"test2",
  300
+                                     "wizard_step": "1"})
  301
+        self.assertEquals(0, response.context['step0'])
  302
+
  303
+    def test_good_hash_django12(self):
  304
+        """
  305
+        Form should advance if the hash is present and good, as calculated using
  306
+        django 1.2 method.
  307
+        """
  308
+        # We are hard-coding a hash value here, but that is OK, since we want to
  309
+        # ensure that we don't accidentally change the algorithm.
  310
+        data = {"0-field": "test",
  311
+                "1-field": "test2",
  312
+                "hash_0": "2fdbefd4c0cad51509478fbacddf8b13",
  313
+                "wizard_step": "1"}
  314
+        response = self.client.post('/wizard/', data)
  315
+        self.assertEquals(2, response.context['step0'])
  316
+
  317
+    def test_good_hash_django12_subclass(self):
  318
+        """
  319
+        The Django 1.2 method of calulating hashes should *not* be used as a
  320
+        fallback if the FormWizard subclass has provided their own method
  321
+        of calculating a hash.
  322
+        """
  323
+        # We are hard-coding a hash value here, but that is OK, since we want to
  324
+        # ensure that we don't accidentally change the algorithm.
  325
+        data = {"0-field": "test",
  326
+                "1-field": "test2",
  327
+                "hash_0": "2fdbefd4c0cad51509478fbacddf8b13",
  328
+                "wizard_step": "1"}
  329
+        response = self.client.post('/wizard2/', data)
  330
+        self.assertEquals(0, response.context['step0'])
  331
+
  332
+    def test_good_hash_current(self):
  333
+        """
  334
+        Form should advance if the hash is present and good, as calculated using
  335
+        current method.
  336
+        """
  337
+        data = {"0-field": "test",
  338
+                "1-field": "test2",
  339
+                "hash_0": "7e9cea465f6a10a6fb47fcea65cb9a76350c9a5c",
  340
+                "wizard_step": "1"}
  341
+        response = self.client.post('/wizard/', data)
  342
+        self.assertEquals(2, response.context['step0'])
9  django/contrib/formtools/tests/templates/formwizard/wizard.html
... ...
@@ -0,0 +1,9 @@
  1
+<p>Step {{ step }} of {{ step_count }}</p>
  2
+<form action="." method="post">{% csrf_token %}
  3
+<table>
  4
+{{ form }}
  5
+</table>
  6
+<input type="hidden" name="{{ step_field }}" value="{{ step0 }}" />
  7
+{{ previous_fields|safe }}
  8
+<input type="submit">
  9
+</form>
17  django/contrib/formtools/tests/urls.py
... ...
@@ -0,0 +1,17 @@
  1
+"""
  2
+This is a URLconf to be loaded by tests.py. Add any URLs needed for tests only.
  3
+"""
  4
+
  5
+from django.conf.urls.defaults import *
  6
+from django.contrib.formtools.tests import *
  7
+
  8
+urlpatterns = patterns('',
  9
+                       (r'^test1/', TestFormPreview(TestForm)),
  10
+                       (r'^test2/', UserSecuredFormPreview(TestForm)),
  11
+                       (r'^wizard/$', WizardClass([WizardPageOneForm,
  12
+                                                   WizardPageTwoForm,
  13
+                                                   WizardPageThreeForm])),
  14
+                       (r'^wizard2/$', UserSecuredWizardClass([WizardPageOneForm,
  15
+                                                               WizardPageTwoForm,
  16
+                                                               WizardPageThreeForm]))
  17
+                      )
28  django/contrib/formtools/utils.py
@@ -4,8 +4,10 @@
4 4
     import pickle
5 5
 
6 6
 from django.conf import settings
7  
-from django.utils.hashcompat import md5_constructor
8 7
 from django.forms import BooleanField
  8
+from django.utils.crypto import salted_hmac
  9
+from django.utils.hashcompat import md5_constructor
  10
+
9 11
 
10 12
 def security_hash(request, form, *args):
11 13
     """
@@ -15,7 +17,9 @@ def security_hash(request, form, *args):
15 17
     order, pickles the result with the SECRET_KEY setting, then takes an md5
16 18
     hash of that.
17 19
     """
18  
-
  20
+    import warnings
  21
+    warnings.warn("security_hash is deprecated; use form_hmac instead",
  22
+                  PendingDeprecationWarning)
19 23
     data = []
20 24
     for bf in form:
21 25
         # Get the value from the form data. If the form allows empty or hasn't
@@ -37,3 +41,23 @@ def security_hash(request, form, *args):
37 41
 
38 42
     return md5_constructor(pickled).hexdigest()
39 43
 
  44
+
  45
+def form_hmac(form):
  46
+    """
  47
+    Calculates a security hash for the given Form instance.
  48
+    """
  49
+    data = []
  50
+    for bf in form:
  51
+        # Get the value from the form data. If the form allows empty or hasn't
  52
+        # changed then don't call clean() to avoid trigger validation errors.
  53
+        if form.empty_permitted and not form.has_changed():
  54
+            value = bf.data or ''
  55
+        else:
  56
+            value = bf.field.clean(bf.data) or ''
  57
+        if isinstance(value, basestring):
  58
+            value = value.strip()
  59
+        data.append((bf.name, value))
  60
+
  61
+    pickled = pickle.dumps(data, pickle.HIGHEST_PROTOCOL)
  62
+    key_salt = 'django.contrib.formtools'
  63
+    return salted_hmac(key_salt, pickled).hexdigest()
43  django/contrib/formtools/wizard.py
@@ -8,12 +8,13 @@
8 8
 
9 9
 from django import forms
10 10
 from django.conf import settings
  11
+from django.contrib.formtools.utils import security_hash, form_hmac
11 12
 from django.http import Http404
12 13
 from django.shortcuts import render_to_response
13 14
 from django.template.context import RequestContext
  15
+from django.utils.crypto import constant_time_compare
14 16
 from django.utils.hashcompat import md5_constructor
15 17
 from django.utils.translation import ugettext_lazy as _
16  
-from django.contrib.formtools.utils import security_hash
17 18
 from django.utils.decorators import method_decorator
18 19
 from django.views.decorators.csrf import csrf_protect
19 20
 
@@ -53,6 +54,27 @@ def num_steps(self):
53 54
         # hook methods might alter self.form_list.
54 55
         return len(self.form_list)
55 56
 
  57
+    def _check_security_hash(self, token, request, form):
  58
+        expected = self.security_hash(request, form)
  59
+        if constant_time_compare(token, expected):
  60
+            return True
  61
+        else:
  62
+            # Fall back to Django 1.2 method, for compatibility with forms that
  63
+            # are in the middle of being used when the upgrade occurs. However,
  64
+            # we don't want to do this fallback if a subclass has provided their
  65
+            # own security_hash method - because they might have implemented a
  66
+            # more secure method, and this would punch a hole in that.
  67
+
  68
+            # PendingDeprecationWarning <- left here to remind us that this
  69
+            # compatibility fallback should be removed in Django 1.5
  70
+            FormWizard_expected = FormWizard.security_hash(self, request, form)
  71
+            if expected == FormWizard_expected:
  72
+                # They didn't override security_hash, do the fallback:
  73
+                old_expected = security_hash(request, form)
  74
+                return constant_time_compare(token, old_expected)
  75
+            else:
  76
+                return False
  77
+
56 78
     @method_decorator(csrf_protect)
57 79
     def __call__(self, request, *args, **kwargs):
58 80
         """
@@ -72,7 +94,7 @@ def __call__(self, request, *args, **kwargs):
72 94
         # TODO: Move "hash_%d" to a method to make it configurable.
73 95
         for i in range(current_step):
74 96
             form = self.get_form(i, request.POST)
75  
-            if request.POST.get("hash_%d" % i, '') != self.security_hash(request, form):
  97
+            if not self._check_security_hash(request.POST.get("hash_%d" % i, ''), request, form):
76 98
                 return self.render_hash_failure(request, i)
77 99
             self.process_step(request, form, i)
78 100
 
@@ -95,6 +117,21 @@ def __call__(self, request, *args, **kwargs):
95 117
                 # Validate all the forms. If any of them fail validation, that
96 118
                 # must mean the validator relied on some other input, such as
97 119
                 # an external Web site.
  120
+
  121
+                # It is also possible that validation might fail under certain
  122
+                # attack situations: an attacker might be able to bypass previous
  123
+                # stages, and generate correct security hashes for all the
  124
+                # skipped stages by virtue of:
  125
+                #  1) having filled out an identical form which doesn't have the
  126
+                #     validation (and does something different at the end),
  127
+                #  2) or having filled out a previous version of the same form
  128
+                #     which had some validation missing,
  129
+                #  3) or previously having filled out the form when they had
  130
+                #     more privileges than they do now.
  131
+                #
  132
+                # Since the hashes only take into account values, and not other
  133
+                # other validation the form might do, we must re-do validation
  134
+                # now for security reasons.
98 135
                 for i, f in enumerate(final_form_list):
99 136
                     if not f.is_valid():
100 137
                         return self.render_revalidation_failure(request, i, f)
@@ -155,7 +192,7 @@ def security_hash(self, request, form):
155 192
         Subclasses may want to take into account request-specific information,
156 193
         such as the IP address.
157 194
         """
158  
-        return security_hash(request, form)
  195
+        return form_hmac(form)
159 196
 
160 197
     def determine_step(self, request, *args, **kwargs):
161 198
         """
10  django/contrib/messages/storage/cookie.py
... ...
@@ -1,11 +1,9 @@
@@ -111,8 +109,8 @@ def _hash(self, value):
@@ -139,7 +137,7 @@ def _decode(self, data):
41  django/contrib/sessions/backends/base.py
@@ -12,6 +12,7 @@
12 12
 from django.conf import settings
13 13
 from django.core.exceptions import SuspiciousOperation
14 14
 from django.utils.hashcompat import md5_constructor
  15
+from django.utils.crypto import constant_time_compare, salted_hmac
15 16
 
16 17
 # Use the system (hardware-based) random number generator if it exists.
17 18
 if hasattr(random, 'SystemRandom'):
@@ -83,23 +84,45 @@ def test_cookie_worked(self):
83 84
     def delete_test_cookie(self):
84 85
         del self[self.TEST_COOKIE_NAME]
85 86
 
  87
+    def _hash(self, value):
  88
+        key_salt = "django.contrib.sessions" + self.__class__.__name__
  89
+        return salted_hmac(key_salt, value).hexdigest()
  90
+
86 91
     def encode(self, session_dict):
87 92
         "Returns the given session dictionary pickled and encoded as a string."
88 93
         pickled = pickle.dumps(session_dict, pickle.HIGHEST_PROTOCOL)
89  
-        pickled_md5 = md5_constructor(pickled + settings.SECRET_KEY).hexdigest()
90  
-        return base64.encodestring(pickled + pickled_md5)
  94
+        hash = self._hash(pickled)
  95
+        return base64.encodestring(hash + ":" + pickled)
91 96
 
92 97
     def decode(self, session_data):
93 98
         encoded_data = base64.decodestring(session_data)
  99
+        try:
  100
+            # could produce ValueError if there is no ':'
  101
+            hash, pickled = encoded_data.split(':', 1)
  102
+            expected_hash = self._hash(pickled)
  103
+            if not constant_time_compare(hash, expected_hash):
  104
+                raise SuspiciousOperation("Session data corrupted")
  105
+            else:
  106
+                return pickle.loads(pickled)
  107
+        except Exception:
  108
+            # ValueError, SuspiciousOperation, unpickling exceptions
  109
+            # Fall back to Django 1.2 method
  110
+            # PendingDeprecationWarning <- here to remind us to
  111
+            # remove this fallback in Django 1.5
  112
+            try:
  113
+                return self._decode_old(session_data)
  114
+            except Exception:
  115
+                # Unpickling can cause a variety of exceptions. If something happens,
  116
+                # just return an empty dictionary (an empty session).
  117
+                return {}
  118
+
  119
+    def _decode_old(self, session_data):
  120
+        encoded_data = base64.decodestring(session_data)
94 121
         pickled, tamper_check = encoded_data[:-32], encoded_data[-32:]
95  
-        if md5_constructor(pickled + settings.SECRET_KEY).hexdigest() != tamper_check:
  122
+        if not constant_time_compare(md5_constructor(pickled + settings.SECRET_KEY).hexdigest(),
  123
+                                     tamper_check):
96 124
             raise SuspiciousOperation("User tampered with session cookie.")
97  
-        try:
98  
-            return pickle.loads(pickled)
99  
-        # Unpickling can cause a variety of exceptions. If something happens,
100  
-        # just return an empty dictionary (an empty session).
101  
-        except:
102  
-            return {}
  125
+        return pickle.loads(pickled)
103 126
 
104 127
     def update(self, dict_):
105 128
         self._session.update(dict_)
21  django/contrib/sessions/tests.py
... ...
@@ -1,4 +1,6 @@
  1
+import base64
1 2
 from datetime import datetime, timedelta
  3
+import pickle
2 4
 import shutil
3 5
 import tempfile
4 6
 
@@ -12,6 +14,7 @@
12 14
 from django.core.exceptions import ImproperlyConfigured
13 15
 from django.test import TestCase
14 16
 from django.utils import unittest
  17
+from django.utils.hashcompat import md5_constructor
15 18
 
16 19
 
17 20
 class SessionTestsMixin(object):
@@ -237,6 +240,24 @@ def test_get_expire_at_browser_close(self):
237 240
         finally:
238 241
             settings.SESSION_EXPIRE_AT_BROWSER_CLOSE = original_expire_at_browser_close
239 242
 
  243
+    def test_decode(self):
  244
+        # Ensure we can decode what we encode
  245
+        data = {'a test key': 'a test value'}
  246
+        encoded = self.session.encode(data)
  247
+        self.assertEqual(self.session.decode(encoded), data)
  248
+
  249
+    def test_decode_django12(self):
  250
+        # Ensure we can decode values encoded using Django 1.2
  251
+        # Hard code the Django 1.2 method here:
  252
+        def encode(session_dict):
  253
+            pickled = pickle.dumps(session_dict, pickle.HIGHEST_PROTOCOL)
  254
+            pickled_md5 = md5_constructor(pickled + settings.SECRET_KEY).hexdigest()
  255
+            return base64.encodestring(pickled + pickled_md5)
  256
+
  257
+        data = {'a test key': 'a test value'}
  258
+        encoded = encode(data)
  259
+        self.assertEqual(self.session.decode(encoded), data)
  260
+
240 261
 
241 262
 class DatabaseSessionTests(SessionTestsMixin, TestCase):
242 263
 
5  django/middleware/csrf.py
@@ -15,6 +15,7 @@
15 15
 from django.utils.hashcompat import md5_constructor
16 16
 from django.utils.log import getLogger
17 17
 from django.utils.safestring import mark_safe
  18
+from django.utils.crypto import constant_time_compare
18 19
 
19 20
 _POST_FORM_RE = \
20 21
     re.compile(r'(<form\W[^>]*\bmethod\s*=\s*(\'|"|)POST(\'|"|)\b[^>]*>)', re.IGNORECASE)
@@ -216,8 +217,8 @@ def accept():
216 217
                 csrf_token = request.META["CSRF_COOKIE"]
217 218
 
218 219
             # check incoming token
219  
-            request_csrf_token = request.POST.get('csrfmiddlewaretoken', None)
220  
-            if request_csrf_token != csrf_token:
  220
+            request_csrf_token = request.POST.get('csrfmiddlewaretoken', '')
  221
+            if not constant_time_compare(request_csrf_token, csrf_token):
221 222
                 if cookie_is_new:
222 223
                     # probably a problem setting the CSRF cookie
223 224
                     logger.warning('Forbidden (%s): %s' % (REASON_NO_CSRF_COOKIE, request.path),
45  django/utils/crypto.py
... ...
@@ -0,0 +1,45 @@
  1
+"""
  2
+Django's standard crypto functions and utilities.
  3
+"""
  4
+import hmac
  5
+
  6
+from django.conf import settings
  7
+from django.utils.hashcompat import sha_constructor
  8
+
  9
+
  10
+def salted_hmac(key_salt, value, secret=None):
  11
+    """
  12
+    Returns the HMAC-SHA1 of 'value', using a key generated from key_salt and a
  13
+    secret (which defaults to settings.SECRET_KEY).
  14
+
  15
+    A different key_salt should be passed in for every application of HMAC.
  16
+    """
  17
+    if secret is None:
  18
+        secret = settings.SECRET_KEY
  19
+
  20
+    # We need to generate a derived key from our base key.  We can do this by
  21
+    # passing the key_salt and our base key through a pseudo-random function and
  22
+    # SHA1 works nicely.
  23
+
  24
+    key = sha_constructor(key_salt + secret).digest()
  25
+
  26
+    # If len(key_salt + secret) > sha_constructor().block_size, the above
  27
+    # line is redundant and could be replaced by key = key_salt + secret, since
  28
+    # the hmac module does the same thing for keys longer than the block size.
  29
+    # However, we need to ensure that we *always* do this.
  30
+
  31
+    return hmac.new(key, msg=value, digestmod=sha_constructor)
  32
+
  33
+
  34
+def constant_time_compare(val1, val2):
  35
+    """
  36
+    Returns True if the two strings are equal, False otherwise.
  37
+
  38
+    The time taken is independent of the number of characters that match.
  39
+    """
  40
+    if len(val1) != len(val2):
  41
+        return False
  42
+    result = 0
  43
+    for x, y in zip(val1, val2):
  44
+        result |= ord(x) ^ ord(y)
  45
+    return result == 0
4  docs/internals/deprecation.txt
@@ -114,6 +114,10 @@ their deprecation, as per the :ref:`Django deprecation policy
114 114
           :class:`~django.test.simple.DjangoTestRunner` will be removed in
115 115
           favor of using the unittest-native class.
116 116
 
  117
+        * The undocumented function
  118
+          :func:`django.contrib.formtools.utils.security_hash`
  119
+          is deprecated, in favour of :func:`django.contrib.formtools.utils.form_hmac`
  120
+
117 121
     * 2.0
118 122
         * ``django.views.defaults.shortcut()``. This function has been moved
119 123
           to ``django.contrib.contenttypes.views.shortcut()`` as part of the
2  docs/ref/contrib/formtools/form-wizard.txt
@@ -240,7 +240,7 @@ Advanced ``FormWizard`` methods
240 240
     Calculates the security hash for the given request object and
241 241
     :class:`~django.forms.Form` instance.
242 242
 
243  
-    By default, this uses an MD5 hash of the form data and your
  243
+    By default, this generates a SHA1 HMAC using your form data and your
244 244
     :setting:`SECRET_KEY` setting. It's rare that somebody would need to
245 245
     override this.
246 246
 
18  tests/regressiontests/comment_tests/tests/comment_form_tests.py
@@ -2,6 +2,7 @@
2 2
 from django.conf import settings
3 3
 from django.contrib.comments.models import Comment
4 4
 from django.contrib.comments.forms import CommentForm
  5
+from django.utils.hashcompat import sha_constructor
5 6
 from regressiontests.comment_tests.models import Article
6 7
 from regressiontests.comment_tests.tests import CommentTestCase
7 8
 
@@ -43,6 +44,23 @@ def testContentTypeTampering(self):
43 44
     def testObjectPKTampering(self):
44 45
         self.tamperWithForm(object_pk="3")
45 46
 
  47
+    def testDjango12Hash(self):
  48
+        # Ensure we can use the hashes generated by Django 1.2
  49
+        a = Article.objects.get(pk=1)
  50
+        d = self.getValidData(a)
  51
+
  52
+        content_type = d['content_type']
  53
+        object_pk = d['object_pk']
  54
+        timestamp = d['timestamp']
  55
+
  56
+        # The Django 1.2 method hard-coded here:
  57
+        info = (content_type, object_pk, timestamp, settings.SECRET_KEY)
  58
+        security_hash = sha_constructor("".join(info)).hexdigest()
  59
+
  60
+        d['security_hash'] = security_hash
  61
+        f = CommentForm(a, data=d)
  62
+        self.assertTrue(f.is_valid(), f.errors)
  63
+
46 64
     def testSecurityErrors(self):
47 65
         f = self.tamperWithForm(honeypot="I am a robot")
48 66
         self.assert_("honeypot" in f.security_errors())

0 notes on commit 45c7f42

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