Repository: django-oscar/django-oscar

Issue:
Anonymous order verification hash uses insecure md5
In `src/oscar/apps/order/abstract_models.py`, the `AbstractOrder` model has a `verification_hash` method, which uses the insecure MD5 hashing algorithm to compute a verification code.

I Am Not A Cryptographer, but my spidey sense is tingling...

- `key = '%s%s' % (self.number, settings.SECRET_KEY)`  <- user already knows the number; hash input is a simple concatenation of number and secret key
- `hash = hashlib.md5(key.encode('utf8'))` <- hash is also known to the user

Wouldn't that make the site-wide secret key recoverable from an anonymous order URL?



Discussion:
Yep, this is an issue. Working on a patch.

Resolving patch:
diff --git a/src/oscar/apps/customer/app.py b/src/oscar/apps/customer/app.py
--- a/src/oscar/apps/customer/app.py
+++ b/src/oscar/apps/customer/app.py
@@ -96,7 +96,7 @@ def get_urls(self):
             url(r'^orders/$',
                 login_required(self.order_history_view.as_view()),
                 name='order-list'),
-            url(r'^order-status/(?P<order_number>[\w-]*)/(?P<hash>\w+)/$',
+            url(r'^order-status/(?P<order_number>[\w-]*)/(?P<hash>[A-z0-9-_=:]+)/$',
                 self.anon_order_detail_view.as_view(), name='anon-order'),
             url(r'^orders/(?P<order_number>[\w-]*)/$',
                 login_required(self.order_detail_view.as_view()),
diff --git a/src/oscar/apps/customer/views.py b/src/oscar/apps/customer/views.py
--- a/src/oscar/apps/customer/views.py
+++ b/src/oscar/apps/customer/views.py
@@ -604,7 +604,7 @@ def get_object(self, queryset=None):
         # Check URL hash matches that for order to prevent spoof attacks
         order = get_object_or_404(self.model, user=None,
                                   number=self.kwargs['order_number'])
-        if self.kwargs['hash'] != order.verification_hash():
+        if not order.check_verification_hash(self.kwargs['hash']):
             raise http.Http404()
         return order
 
diff --git a/src/oscar/apps/order/abstract_models.py b/src/oscar/apps/order/abstract_models.py
--- a/src/oscar/apps/order/abstract_models.py
+++ b/src/oscar/apps/order/abstract_models.py
@@ -1,11 +1,15 @@
 import hashlib
+import logging
 from collections import OrderedDict
 from decimal import Decimal as D
 
 from django.conf import settings
+from django.core.exceptions import ImproperlyConfigured
+from django.core.signing import BadSignature, Signer
 from django.db import models
 from django.db.models import Sum
 from django.utils import timezone
+from django.utils.crypto import constant_time_compare
 from django.utils.encoding import python_2_unicode_compatible
 from django.utils.timezone import now
 from django.utils.translation import ugettext_lazy as _
@@ -21,6 +25,9 @@
 from . import exceptions
 
 
+logger = logging.getLogger('oscar.order')
+
+
 @python_2_unicode_compatible
 class AbstractOrder(models.Model):
     """
@@ -299,9 +306,48 @@ def __str__(self):
         return u"#%s" % (self.number,)
 
     def verification_hash(self):
-        key = '%s%s' % (self.number, settings.SECRET_KEY)
-        hash = hashlib.md5(key.encode('utf8'))
-        return hash.hexdigest()
+        signer = Signer(salt='oscar.apps.order.Order')
+        return signer.sign(self.number)
+
+    def check_deprecated_verification_hash(self, hash_to_check):
+        """
+        Backward compatible check for md5 hashes that were generated in
+        Oscar 1.5 and lower.
+
+        This must explicitly be enabled by setting OSCAR_DEPRECATED_ORDER_VERIFY_KEY,
+        which must not be equal to SECRET_KEY - i.e., the project must
+        have changed its SECRET_KEY since this change was applied.
+
+        TODO: deprecate this method in Oscar 2.0, and remove it in Oscar 2.1.
+        """
+        old_verification_key = getattr(settings, 'OSCAR_DEPRECATED_ORDER_VERIFY_KEY', None)
+        if old_verification_key is None:
+            return False
+
+        if old_verification_key == settings.SECRET_KEY:
+            raise ImproperlyConfigured(
+                'OSCAR_DEPRECATED_ORDER_VERIFY_KEY cannot be equal to SECRET_KEY')
+
+        logger.warning('Using insecure md5 hashing for order URL hash verification.')
+        string_to_hash = '%s%s' % (self.number, old_verification_key)
+        order_hash = hashlib.md5(string_to_hash.encode('utf8')).hexdigest()
+        return constant_time_compare(order_hash, hash_to_check)
+
+    def check_verification_hash(self, hash_to_check):
+        """
+        Checks the received verification hash against this order number.
+        Returns False if the verification failed, True otherwise.
+        """
+        if self.check_deprecated_verification_hash(hash_to_check):
+            return True
+
+        signer = Signer(salt='oscar.apps.order.Order')
+        try:
+            signed_number = signer.unsign(hash_to_check)
+        except BadSignature:
+            return False
+
+        return constant_time_compare(signed_number, self.number)
 
     @property
     def email(self):


Test patch:
diff --git a/tests/integration/order/test_models.py b/tests/integration/order/test_models.py
--- a/tests/integration/order/test_models.py
+++ b/tests/integration/order/test_models.py
@@ -1,7 +1,8 @@
 from datetime import timedelta, datetime
 from decimal import Decimal as D
 
-from django.test import TestCase
+from django.core.exceptions import ImproperlyConfigured
+from django.test import TestCase, override_settings
 from django.utils import timezone
 from django.utils.translation import ugettext_lazy as _
 import mock
@@ -391,3 +392,50 @@ def test_shipping_status(self):
         # Set second line to returned
         event_2.line_quantities.create(line=line_2, quantity=1)
         self.assertEqual(order.shipping_status, _('Returned'))
+
+    @override_settings(SECRET_KEY='order_hash_secret')
+    def test_verification_hash_generation(self):
+        order = OrderFactory(number='111000')
+        self.assertEqual(order.verification_hash(), '111000:UJrZWNPLsq7zf1r17c3v1Q6DUmE')
+
+    @override_settings(SECRET_KEY='order_hash_secret')
+    def test_check_verification_hash_valid(self):
+        order = OrderFactory(number='111000')
+        self.assertTrue(order.check_verification_hash('111000:UJrZWNPLsq7zf1r17c3v1Q6DUmE'))
+
+    @override_settings(SECRET_KEY='order_hash_secret')
+    def test_check_verification_hash_invalid_signature(self):
+        order = OrderFactory(number='111000')
+        self.assertFalse(order.check_verification_hash('111000:HKDZWNPLsq7589517c3v1Q6DHKD'))
+
+    @override_settings(SECRET_KEY='order_hash_secret')
+    def test_check_verification_hash_valid_signature_but_wrong_number(self):
+        order = OrderFactory(number='111000')
+        # Hash is valid, but it is for a different order number
+        self.assertFalse(order.check_verification_hash('222000:knvoMB1KAiJu8meWtGce00Y88j4'))
+
+    @override_settings(OSCAR_DEPRECATED_ORDER_VERIFY_KEY='deprecated_order_hash_secret')
+    def test_check_deprecated_hash_verification(self):
+        order = OrderFactory(number='100001')
+        # Check that check_deprecated_verification_hash validates the hash
+        self.assertTrue(
+            order.check_deprecated_verification_hash('3efd0339e8c789447469f37851cbaaaf')
+        )
+        # Check that check_verification_hash calls it correctly
+        self.assertTrue(order.check_verification_hash('3efd0339e8c789447469f37851cbaaaf'))
+
+    def test_check_deprecated_hash_verification_without_old_key(self):
+        order = OrderFactory(number='100001')
+        # Check that check_deprecated_verification_hash validates the hash
+        self.assertFalse(
+            order.check_deprecated_verification_hash('3efd0339e8c789447469f37851cbaaaf')
+        )
+
+    @override_settings(
+        OSCAR_DEPRECATED_ORDER_VERIFY_KEY='deprecated_order_hash_secret',
+        SECRET_KEY='deprecated_order_hash_secret')
+    def test_check_deprecated_hash_verification_old_key_matches_new(self):
+        order = OrderFactory(number='100001')
+        # OSCAR_DEPRECATED_ORDER_VERIFY_KEY must not be equal to SECRET_KEY.
+        with self.assertRaises(ImproperlyConfigured):
+            order.check_deprecated_verification_hash('3efd0339e8c789447469f37851cbaaaf')