Merge pull request #39 from ProReNata/master

Added support for signed webhooks
This commit is contained in:
Mike Edmunds
2013-06-15 14:33:23 -07:00
5 changed files with 97 additions and 3 deletions

11
djrill/compat.py Normal file
View File

@@ -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]

View File

@@ -1,9 +1,13 @@
from base64 import b64encode
import hashlib
import hmac
import json import json
from django.test import TestCase from django.test import TestCase
from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import ImproperlyConfigured
from django.conf import settings from django.conf import settings
from ..compat import b
from ..signals import webhook_event from ..signals import webhook_event
@@ -39,6 +43,37 @@ class DjrillWebhookSecretMixinTests(TestCase):
self.assertEqual(response.status_code, 200) 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): class DjrillWebhookViewTests(TestCase):
""" """
Test optional Mandrill webhook view Test optional Mandrill webhook view

View File

@@ -1,5 +1,7 @@
from base64 import b64encode
import hashlib
import hmac
import json import json
from django import forms from django import forms
from django.conf import settings from django.conf import settings
from django.contrib import messages from django.contrib import messages
@@ -12,6 +14,7 @@ from django.views.decorators.csrf import csrf_exempt
import requests import requests
from djrill import MANDRILL_API_URL, signals from djrill import MANDRILL_API_URL, signals
from .compat import b
class DjrillAdminMedia(object): class DjrillAdminMedia(object):
@@ -101,6 +104,41 @@ class DjrillWebhookSecretMixin(object):
request, *args, **kwargs) 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): class DjrillIndexView(DjrillApiMixin, TemplateView):
template_name = "djrill/status.html" template_name = "djrill/status.html"
@@ -161,7 +199,7 @@ class DjrillUrlListView(DjrillAdminMedia, DjrillApiMixin,
return self.render_to_response(context) return self.render_to_response(context)
class DjrillWebhookView(DjrillWebhookSecretMixin, View): class DjrillWebhookView(DjrillWebhookSecretMixin, DjrillWebhookSignatureMixin, View):
def head(self, request, *args, **kwargs): def head(self, request, *args, **kwargs):
return HttpResponse() return HttpResponse()

View File

@@ -3,6 +3,7 @@ Release Notes
Version 0.6 (development): Version 0.6 (development):
* Support for signed webhooks
Version 0.5: Version 0.5:

View File

@@ -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 app and Mandrill. Djrill will verify calls to your webhook, and will
reject calls without the correct key. 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 .. _Mandrill webhooks: http://help.mandrill.com/entries/21738186-Introduction-to-Webhooks
.. _securing webhooks: http://apidocs.mailchimp.com/webhooks/#securing-webhooks .. _securing webhooks: http://apidocs.mailchimp.com/webhooks/#securing-webhooks
.. _webhook signature: http://help.mandrill.com/entries/23704122-Authenticating-webhook-requests
.. _webhooks-config: .. _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 the *name* of the "secret" query string parameter, you can set
:setting:`DJRILL_WEBHOOK_SECRET_NAME` in your :file:`settings.py`. :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 .. _webhooks control panel: https://mandrillapp.com/settings/webhooks
.. _inbound settings: https://mandrillapp.com/inbound .. _inbound settings: https://mandrillapp.com/inbound