diff --git a/djrill/compat.py b/djrill/compat.py new file mode 100644 index 0000000..9c22d16 --- /dev/null +++ b/djrill/compat.py @@ -0,0 +1,11 @@ +# For python 3 compatibility, see http://python3porting.com/problems.html#nicer-solutions +import sys + +if sys.version < '3': + def b(x): + return x +else: + import codecs + + def b(x): + return codecs.latin_1_encode(x)[0] \ No newline at end of file diff --git a/djrill/tests/test_mandrill_webhook.py b/djrill/tests/test_mandrill_webhook.py index c4fd707..d2969e4 100644 --- a/djrill/tests/test_mandrill_webhook.py +++ b/djrill/tests/test_mandrill_webhook.py @@ -1,9 +1,13 @@ +from base64 import b64encode +import hashlib +import hmac import json from django.test import TestCase from django.core.exceptions import ImproperlyConfigured from django.conf import settings +from ..compat import b from ..signals import webhook_event @@ -39,6 +43,37 @@ class DjrillWebhookSecretMixinTests(TestCase): self.assertEqual(response.status_code, 200) +class DjrillWebhookSignatureMixinTests(TestCase): + """ + Test mixin used in optional Mandrill webhook signature support + """ + + def setUp(self): + settings.DJRILL_WEBHOOK_SECRET = 'abc123' + settings.DJRILL_WEBHOOK_SIGNATURE_KEY = "signature" + settings.DJRILL_WEBHOOK_URL = "/webhook/?secret=abc123" + + def test_incorrect_settings(self): + del settings.DJRILL_WEBHOOK_URL + with self.assertRaises(ImproperlyConfigured): + self.client.post('/webhook/?secret=abc123') + settings.DJRILL_WEBHOOK_URL = "/webhook/?secret=abc123" + + def test_unauthorized(self): + settings.DJRILL_WEBHOOK_SIGNATURE_KEY = "anothersignature" + response = self.client.post(settings.DJRILL_WEBHOOK_URL) + self.assertEqual(response.status_code, 403) + + def test_signature(self): + signature = hmac.new(key=b(settings.DJRILL_WEBHOOK_SIGNATURE_KEY), msg = b(settings.DJRILL_WEBHOOK_URL+"mandrill_events[]"), digestmod=hashlib.sha1) + hash_string = b64encode(signature.digest()) + response = self.client.post('/webhook/?secret=abc123', data={"mandrill_events":"[]"}, **{"HTTP_X_MANDRILL_SIGNATURE" : hash_string}) + self.assertEqual(response.status_code, 200) + + def tearDown(self): + del settings.DJRILL_WEBHOOK_SIGNATURE_KEY + del settings.DJRILL_WEBHOOK_URL + class DjrillWebhookViewTests(TestCase): """ Test optional Mandrill webhook view diff --git a/djrill/views.py b/djrill/views.py index 8a420cf..3e76b85 100644 --- a/djrill/views.py +++ b/djrill/views.py @@ -1,5 +1,7 @@ +from base64 import b64encode +import hashlib +import hmac import json - from django import forms from django.conf import settings from django.contrib import messages @@ -12,6 +14,7 @@ from django.views.decorators.csrf import csrf_exempt import requests from djrill import MANDRILL_API_URL, signals +from .compat import b class DjrillAdminMedia(object): @@ -101,6 +104,41 @@ class DjrillWebhookSecretMixin(object): request, *args, **kwargs) +class DjrillWebhookSignatureMixin(object): + + @method_decorator(csrf_exempt) + def dispatch(self, request, *args, **kwargs): + + signature_key = getattr(settings, 'DJRILL_WEBHOOK_SIGNATURE_KEY', None) + + if signature_key and request.method == "POST": + + # Make webhook url an explicit setting to make sure that this is the exact same string + # that the user entered in Mandrill + post_string = getattr(settings, "DJRILL_WEBHOOK_URL", None) + if post_string is None: + raise ImproperlyConfigured( + "You have set DJRILL_WEBHOOK_SIGNATURE_KEY, but haven't set DJRILL_WEBHOOK_URL in the settings file.") + + signature = request.META.get("HTTP_X_MANDRILL_SIGNATURE", None) + if not signature: + return HttpResponse(status=403, content="X-Mandrill-Signature not set") + + # The querydict is a bit special, see https://docs.djangoproject.com/en/dev/ref/request-response/#querydict-objects + # Mandrill needs it to be sorted and added to the hash + post_lists = sorted(request.POST.lists()) + for value_list in post_lists: + for item in value_list[1]: + post_string += "%s%s" % (value_list[0], item) + + hash_string = b64encode(hmac.new(key=b(signature_key), msg=b(post_string), digestmod=hashlib.sha1).digest()) + if signature != hash_string: + return HttpResponse(status=403, content="Signature doesn't match") + + return super(DjrillWebhookSignatureMixin, self).dispatch( + request, *args, **kwargs) + + class DjrillIndexView(DjrillApiMixin, TemplateView): template_name = "djrill/status.html" @@ -161,7 +199,7 @@ class DjrillUrlListView(DjrillAdminMedia, DjrillApiMixin, return self.render_to_response(context) -class DjrillWebhookView(DjrillWebhookSecretMixin, View): +class DjrillWebhookView(DjrillWebhookSecretMixin, DjrillWebhookSignatureMixin, View): def head(self, request, *args, **kwargs): return HttpResponse() diff --git a/docs/history.rst b/docs/history.rst index b3ce8e7..b04785a 100644 --- a/docs/history.rst +++ b/docs/history.rst @@ -3,6 +3,7 @@ Release Notes Version 0.6 (development): +* Support for signed webhooks Version 0.5: diff --git a/docs/usage/webhooks.rst b/docs/usage/webhooks.rst index 8874cf5..a5cf750 100644 --- a/docs/usage/webhooks.rst +++ b/docs/usage/webhooks.rst @@ -29,10 +29,13 @@ Your code can connect to this signal for further processing. app and Mandrill. Djrill will verify calls to your webhook, and will reject calls without the correct key. + * You can, optionally include the two settings :setting:`DJRILL_WEBHOOK_SIGNATURE_KEY` + and :setting:`DJRILL_WEBHOOK_URL` to enforce `webhook signature`_ checking + .. _Mandrill webhooks: http://help.mandrill.com/entries/21738186-Introduction-to-Webhooks .. _securing webhooks: http://apidocs.mailchimp.com/webhooks/#securing-webhooks - +.. _webhook signature: http://help.mandrill.com/entries/23704122-Authenticating-webhook-requests .. _webhooks-config: @@ -97,6 +100,12 @@ the url config in step 2. And if you'd like to change the *name* of the "secret" query string parameter, you can set :setting:`DJRILL_WEBHOOK_SECRET_NAME` in your :file:`settings.py`. +For extra security, Mandrill provides a signature in the request header +X-Mandrill-Signature. If you want to verify this signature, you need to provide +the settings :setting:`DJRILL_WEBHOOK_SIGNATURE_KEY` with the webhook-specific +signature key that can be found in the Mandrill admin panel and +:setting:`DJRILL_WEBHOOK_URL` where you should enter the exact URL, including +that you entered in Mandrill when creating the webhook. .. _webhooks control panel: https://mandrillapp.com/settings/webhooks .. _inbound settings: https://mandrillapp.com/inbound