From 146afbaf3bcab4d4972b97ffc65fa749ecb13ed4 Mon Sep 17 00:00:00 2001 From: medmunds Date: Fri, 16 Dec 2016 14:24:46 -0800 Subject: [PATCH] Simplify Mandrill webhook validation handshake. Anymail was requiring Mandrill's webhook authentication key for the initial webhook url validation request from Mandrill, but Mandrill doesn't issue the key until that validation request succeeds. * Defer complaining about missing Mandrill webhook key until actual event post. * Document the double-deploy process required to set up Mandrill webhooks. Fixes #46. --- anymail/webhooks/mandrill.py | 12 ++++++++++-- docs/esps/mandrill.rst | 30 +++++++++++++++++++++--------- tests/test_mandrill_webhooks.py | 9 ++++++++- 3 files changed, 39 insertions(+), 12 deletions(-) diff --git a/anymail/webhooks/mandrill.py b/anymail/webhooks/mandrill.py index b1d0c63..f3d13ce 100644 --- a/anymail/webhooks/mandrill.py +++ b/anymail/webhooks/mandrill.py @@ -23,15 +23,23 @@ class MandrillSignatureMixin(object): def __init__(self, **kwargs): # noinspection PyUnresolvedReferences esp_name = self.esp_name - webhook_key = get_anymail_setting('webhook_key', esp_name=esp_name, + # webhook_key is required for POST, but not for HEAD when Mandrill validates webhook url. + # Defer "missing setting" error until we actually try to use it in the POST... + webhook_key = get_anymail_setting('webhook_key', esp_name=esp_name, default=None, kwargs=kwargs, allow_bare=True) - self.webhook_key = webhook_key.encode('ascii') # hmac.new requires bytes key in python 3 + if webhook_key is not None: + self.webhook_key = webhook_key.encode('ascii') # hmac.new requires bytes key in python 3 self.webhook_url = get_anymail_setting('webhook_url', esp_name=esp_name, default=None, kwargs=kwargs, allow_bare=True) # noinspection PyArgumentList super(MandrillSignatureMixin, self).__init__(**kwargs) def validate_request(self, request): + if self.webhook_key is None: + # issue deferred "missing setting" error (re-call get-setting without a default) + # noinspection PyUnresolvedReferences + get_anymail_setting('webhook_key', esp_name=self.esp_name, allow_bare=True) + try: signature = request.META["HTTP_X_MANDRILL_SIGNATURE"] except KeyError: diff --git a/docs/esps/mandrill.rst b/docs/esps/mandrill.rst index 3d579c1..39a1e31 100644 --- a/docs/esps/mandrill.rst +++ b/docs/esps/mandrill.rst @@ -191,20 +191,32 @@ Status tracking webhooks ------------------------ If you are using Anymail's normalized :ref:`status tracking `, -follow `Mandrill's instructions`_ to add Anymail's webhook URL: +setting up Anymail's webhook URL requires deploying your Django project twice: - :samp:`https://{random}:{random}@{yoursite.example.com}/anymail/mandrill/tracking/` +1. First, follow the instructions to + :ref:`configure Anymail's webhooks `. You *must* + deploy before adding the webhook URL to Mandrill, because it will attempt + to verify the URL against your production server. - * *random:random* is an :setting:`ANYMAIL_WEBHOOK_AUTHORIZATION` shared secret - * *yoursite.example.com* is your Django site + Follow `Mandrill's instructions`_ to add Anymail's webhook URL in their settings: -Be sure to check the boxes in the Mandrill settings for the event types you want to receive. -The same Anymail tracking URL can handle all Mandrill "message" and "sync" events. + :samp:`https://{random}:{random}@{yoursite.example.com}/anymail/mandrill/tracking/` + + * *random:random* is an :setting:`ANYMAIL_WEBHOOK_AUTHORIZATION` shared secret + * *yoursite.example.com* is your Django site + + Be sure to check the boxes in the Mandrill settings for the event types you want to receive. + The same Anymail tracking URL can handle all Mandrill "message" and "sync" events. + +2. Mandrill will provide you a "webhook authentication key" once it verifies the URL + is working. Add this to your Django project's Anymail settings under + :setting:`MANDRILL_WEBHOOK_KEY `. + (You may also need to set :setting:`MANDRILL_WEBHOOK_URL ` + depending on your server config.) Then deploy your project again. Mandrill implements webhook signing on the entire event payload, and Anymail will -verify the signature. You must set :setting:`ANYMAIL_MANDRILL_WEBHOOK_KEY` to the -webhook key authentication key issued by Mandrill. You may also need to set -:setting:`ANYMAIL_MANDRILL_WEBHOOK_URL` depending on your server config. +verify the signature. Until the correct webhook key is set, Anymail will raise +an exception for any webhook calls from Mandrill (other than the initial validation request). Mandrill will report these Anymail :attr:`~anymail.signals.AnymailTrackingEvent.event_type`\s: sent, rejected, deferred, bounced, opened, clicked, complained, unsubscribed. Mandrill does diff --git a/tests/test_mandrill_webhooks.py b/tests/test_mandrill_webhooks.py index d5150df..0a72112 100644 --- a/tests/test_mandrill_webhooks.py +++ b/tests/test_mandrill_webhooks.py @@ -41,10 +41,17 @@ def mandrill_args(events=None, url='/anymail/mandrill/tracking/', key=TEST_WEBHO class MandrillWebhookSettingsTestCase(WebhookTestCase): def test_requires_webhook_key(self): - with self.assertRaises(ImproperlyConfigured): + with self.assertRaisesRegex(ImproperlyConfigured, r'MANDRILL_WEBHOOK_KEY'): self.client.post('/anymail/mandrill/tracking/', data={'mandrill_events': '[]'}) + def test_head_does_not_require_webhook_key(self): + # Mandrill issues an unsigned HEAD request to verify the wehbook url. + # Only *after* that succeeds will Mandrill will tell you the webhook key. + # So make sure that HEAD request will go through without any key set: + response = self.client.head('/anymail/mandrill/tracking/') + self.assertEqual(response.status_code, 200) + @override_settings(ANYMAIL_MANDRILL_WEBHOOK_KEY=TEST_WEBHOOK_KEY) class MandrillWebhookSecurityTestCase(WebhookTestCase, WebhookBasicAuthTestsMixin):