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

View File

@@ -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()

View File

@@ -3,6 +3,7 @@ Release Notes
Version 0.6 (development):
* Support for signed webhooks
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
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