Skip to content

Commit 181d588

Browse files
committed
Add MAILGUN_WEBHOOK_SIGNING_KEY setting.
Fixes #153.
1 parent fe6ee5b commit 181d588

File tree

5 files changed

+122
-29
lines changed

5 files changed

+122
-29
lines changed

CHANGELOG.rst

+19
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,22 @@ Release history
2525
^^^^^^^^^^^^^^^
2626
.. This extra heading level keeps the ToC from becoming unmanageably long
2727
28+
vNext
29+
-----
30+
31+
*UNRELEASED*
32+
33+
Fixes
34+
~~~~~
35+
36+
* **Mailgun:** Add new `MAILGUN_WEBHOOK_SIGNING_KEY` setting for verifying tracking and
37+
inbound webhook calls. Mailgun's webhook signing key can become different from your
38+
`MAILGUN_API_KEY` if you have ever rotated either key.
39+
See `docs <https://anymail.readthedocs.io/en/latest/esps/mailgun/#std:setting-ANYMAIL_MAILGUN_WEBHOOK_SIGNING_KEY>`__.
40+
(More in `#153`_. Thanks to `@dominik-lekse`_ for reporting the problem and Mailgun's
41+
`@mbk-ok`_ for identifying the cause.)
42+
43+
2844
v6.0.1
2945
------
3046

@@ -945,17 +961,20 @@ Features
945961
.. _#115: https://github.com/anymail/issues/115
946962
.. _#147: https://github.com/anymail/issues/147
947963
.. _#148: https://github.com/anymail/issues/148
964+
.. _#153: https://github.com/anymail/issues/153
948965

949966
.. _@ailionx: https://github.com/ailionx
950967
.. _@calvin: https://github.com/calvin
951968
.. _@costela: https://github.com/costela
952969
.. _@decibyte: https://github.com/decibyte
970+
.. _@dominik-lekse: https://github.com/dominik-lekse
953971
.. _@ewingrj: https://github.com/ewingrj
954972
.. _@fdemmer: https://github.com/fdemmer
955973
.. _@janneThoft: https://github.com/janneThoft
956974
.. _@joshkersey: https://github.com/joshkersey
957975
.. _@Lekensteyn: https://github.com/Lekensteyn
958976
.. _@lewistaylor: https://github.com/lewistaylor
977+
.. _@mbk-ok: https://github.com/mbk-ok
959978
.. _@RignonNoel: https://github.com/RignonNoel
960979
.. _@sebbacon: https://github.com/sebbacon
961980
.. _@varche1: https://github.com/varche1

anymail/webhooks/mailgun.py

+10-4
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from ..exceptions import AnymailConfigurationError, AnymailWebhookValidationFailure, AnymailInvalidAddress
1111
from ..inbound import AnymailInboundMessage
1212
from ..signals import inbound, tracking, AnymailInboundEvent, AnymailTrackingEvent, EventType, RejectReason
13-
from ..utils import get_anymail_setting, combine, querydict_getfirst, parse_single_address
13+
from ..utils import get_anymail_setting, combine, querydict_getfirst, parse_single_address, UNSET
1414

1515

1616
class MailgunBaseWebhookView(AnymailBaseWebhookView):
@@ -19,12 +19,18 @@ class MailgunBaseWebhookView(AnymailBaseWebhookView):
1919
esp_name = "Mailgun"
2020
warn_if_no_basic_auth = False # because we validate against signature
2121

22+
webhook_signing_key = None # (Declaring class attr allows override by kwargs in View.as_view.)
23+
24+
# The `api_key` attribute name is still allowed for compatibility with earlier Anymail releases.
2225
api_key = None # (Declaring class attr allows override by kwargs in View.as_view.)
2326

2427
def __init__(self, **kwargs):
28+
# webhook_signing_key: falls back to api_key if webhook_signing_key not provided
2529
api_key = get_anymail_setting('api_key', esp_name=self.esp_name,
26-
kwargs=kwargs, allow_bare=True)
27-
self.api_key = api_key.encode('ascii') # hmac.new requires bytes key in python 3
30+
kwargs=kwargs, allow_bare=True, default=None)
31+
webhook_signing_key = get_anymail_setting('webhook_signing_key', esp_name=self.esp_name,
32+
kwargs=kwargs, default=UNSET if api_key is None else api_key)
33+
self.webhook_signing_key = webhook_signing_key.encode('ascii') # hmac.new requires bytes key in python 3
2834
super(MailgunBaseWebhookView, self).__init__(**kwargs)
2935

3036
def validate_request(self, request):
@@ -52,7 +58,7 @@ def validate_request(self, request):
5258
except KeyError:
5359
raise AnymailWebhookValidationFailure("Mailgun webhook called without required security fields")
5460

55-
expected_signature = hmac.new(key=self.api_key, msg='{}{}'.format(timestamp, token).encode('ascii'),
61+
expected_signature = hmac.new(key=self.webhook_signing_key, msg='{}{}'.format(timestamp, token).encode('ascii'),
5662
digestmod=hashlib.sha256).hexdigest()
5763
if not constant_time_compare(signature, expected_signature):
5864
raise AnymailWebhookValidationFailure("Mailgun webhook called with incorrect signature")

docs/esps/mailgun.rst

+48-10
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@ in your settings.py.
2626

2727
.. rubric:: MAILGUN_API_KEY
2828

29-
Required. Your Mailgun API key:
29+
Required for sending. Your Mailgun "Private API key" from the Mailgun
30+
`API security settings`_:
3031

3132
.. code-block:: python
3233
@@ -54,6 +55,27 @@ Mailgun sender domain, this setting is not needed.
5455
See :ref:`mailgun-sender-domain` below for examples.
5556

5657

58+
.. setting:: ANYMAIL_MAILGUN_WEBHOOK_SIGNING_KEY
59+
60+
.. rubric:: MAILGUN_WEBHOOK_SIGNING_KEY
61+
62+
.. versionadded:: 6.1
63+
64+
Required for tracking or inbound webhooks. Your "HTTP webhook signing key" from the
65+
Mailgun `API security settings`_:
66+
67+
.. code-block:: python
68+
69+
ANYMAIL = {
70+
...
71+
"MAILGUN_WEBHOOK_SIGNING_KEY": "<your webhook signing key>",
72+
}
73+
74+
If not provided, Anymail will attempt to validate webhooks using the
75+
:setting:`MAILGUN_API_KEY <ANYMAIL_MAILGUN_API_KEY>` setting instead. (These two keys have
76+
the same values for new Mailgun users, but will diverge if you ever rotate either key.)
77+
78+
5779
.. setting:: ANYMAIL_MAILGUN_API_URL
5880

5981
.. rubric:: MAILGUN_API_URL
@@ -75,6 +97,9 @@ region:
7597
}
7698
7799
100+
.. _API security settings: https://app.mailgun.com/app/account/security/api_keys
101+
102+
78103
.. _mailgun-sender-domain:
79104

80105
Email sender domain
@@ -260,9 +285,14 @@ Status tracking webhooks
260285

261286
Added support for Mailgun's June, 2018 (non-"legacy") webhook format.
262287

288+
.. versionchanged:: 6.1
289+
290+
Added support for a new :setting:`MAILGUN_WEBHOOK_SIGNING_KEY <ANYMAIL_MAILGUN_WEBHOOK_SIGNING_KEY>`
291+
setting, separate from your MAILGUN_API_KEY.
292+
263293
If you are using Anymail's normalized :ref:`status tracking <event-tracking>`, enter
264-
the url in the `Mailgun webhooks dashboard`_. (Be sure to select the correct sending
265-
domain---Mailgun's sandbox and production domains have separate webhook settings.)
294+
the url in the Mailgun webhooks config for your domain. (Be sure to select the correct
295+
sending domain---Mailgun's sandbox and production domains have separate webhook settings.)
266296

267297
Mailgun allows you to enter a different URL for each event type: just enter this same
268298
Anymail tracking URL for all events you want to receive:
@@ -273,8 +303,9 @@ Anymail tracking URL for all events you want to receive:
273303
* *yoursite.example.com* is your Django site
274304

275305
Mailgun implements a limited form of webhook signing, and Anymail will verify
276-
these signatures (based on your :setting:`MAILGUN_API_KEY <ANYMAIL_MAILGUN_API_KEY>`
277-
Anymail setting). By default, Mailgun's webhook signature provides similar security
306+
these signatures against your
307+
:setting:`MAILGUN_WEBHOOK_SIGNING_KEY <ANYMAIL_MAILGUN_WEBHOOK_SIGNING_KEY>`
308+
Anymail setting. By default, Mailgun's webhook signature provides similar security
278309
to Anymail's shared webhook secret, so it's acceptable to omit the
279310
:setting:`ANYMAIL_WEBHOOK_SECRET` setting (and "{random}:{random}@" portion of the
280311
webhook url) with Mailgun webhooks.
@@ -321,7 +352,6 @@ Mailgun's other event APIs.)
321352
newer, non-legacy webhooks.)
322353

323354

324-
.. _Mailgun webhooks dashboard: https://mailgun.com/app/webhooks
325355
.. _Mailgun webhook payload: https://documentation.mailgun.com/en/latest/user_manual.html#webhooks
326356

327357

@@ -333,7 +363,7 @@ Inbound webhook
333363
If you want to receive email from Mailgun through Anymail's normalized :ref:`inbound <inbound>`
334364
handling, follow Mailgun's `Receiving, Storing and Fowarding Messages`_ guide to set up
335365
an inbound route that forwards to Anymail's inbound webhook. (You can configure routes
336-
using Mailgun's API, or simply using the `Mailgun routes dashboard`_.)
366+
using Mailgun's API, or simply using the `Mailgun receiving config`_.)
337367

338368
The *action* for your route will be either:
339369

@@ -352,9 +382,17 @@ received email (including complex forms like multi-message mailing list digests)
352382
If you want to use Anymail's normalized :attr:`~anymail.inbound.AnymailInboundMessage.spam_detected` and
353383
:attr:`~anymail.inbound.AnymailInboundMessage.spam_score` attributes, you'll need to set your Mailgun
354384
domain's inbound spam filter to "Deliver spam, but add X-Mailgun-SFlag and X-Mailgun-SScore headers"
355-
(in the `Mailgun domains dashboard`_).
385+
(in the `Mailgun domains config`_).
386+
387+
Anymail will verify Mailgun inbound message events using your
388+
:setting:`MAILGUN_WEBHOOK_SIGNING_KEY <ANYMAIL_MAILGUN_WEBHOOK_SIGNING_KEY>`
389+
Anymail setting. By default, Mailgun's webhook signature provides similar security
390+
to Anymail's shared webhook secret, so it's acceptable to omit the
391+
:setting:`ANYMAIL_WEBHOOK_SECRET` setting (and "{random}:{random}@" portion of the
392+
action) with Mailgun inbound routing.
393+
356394

357395
.. _Receiving, Storing and Fowarding Messages:
358396
https://documentation.mailgun.com/en/latest/user_manual.html#receiving-forwarding-and-storing-messages
359-
.. _Mailgun routes dashboard: https://app.mailgun.com/app/routes
360-
.. _Mailgun domains dashboard: https://app.mailgun.com/app/domains
397+
.. _Mailgun receiving config: https://app.mailgun.com/app/receiving/routes
398+
.. _Mailgun domains config: https://app.mailgun.com/app/sending/domains

tests/test_mailgun_inbound.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,14 @@
1313
from anymail.webhooks.mailgun import MailgunInboundWebhookView
1414

1515
from .test_mailgun_webhooks import (
16-
TEST_API_KEY, mailgun_sign_payload,
16+
TEST_WEBHOOK_SIGNING_KEY, mailgun_sign_payload,
1717
mailgun_sign_legacy_payload, querydict_to_postdict)
1818
from .utils import sample_image_content, sample_email_content
1919
from .webhook_cases import WebhookTestCase
2020

2121

2222
@tag('mailgun')
23-
@override_settings(ANYMAIL_MAILGUN_API_KEY=TEST_API_KEY)
23+
@override_settings(ANYMAIL_MAILGUN_WEBHOOK_SIGNING_KEY=TEST_WEBHOOK_SIGNING_KEY)
2424
class MailgunInboundTestCase(WebhookTestCase):
2525
def test_inbound_basics(self):
2626
raw_event = mailgun_sign_legacy_payload({

tests/test_mailgun_webhooks.py

+43-13
Original file line numberDiff line numberDiff line change
@@ -14,36 +14,36 @@
1414

1515
from .webhook_cases import WebhookTestCase, WebhookBasicAuthTestsMixin
1616

17-
TEST_API_KEY = 'TEST_API_KEY'
17+
TEST_WEBHOOK_SIGNING_KEY = 'TEST_WEBHOOK_SIGNING_KEY'
1818

1919

20-
def mailgun_signature(timestamp, token, api_key):
20+
def mailgun_signature(timestamp, token, webhook_signing_key):
2121
"""Generates a Mailgun webhook signature"""
2222
# https://documentation.mailgun.com/en/latest/user_manual.html#securing-webhooks
2323
return hmac.new(
24-
key=api_key.encode('ascii'),
24+
key=webhook_signing_key.encode('ascii'),
2525
msg='{timestamp}{token}'.format(timestamp=timestamp, token=token).encode('ascii'),
2626
digestmod=hashlib.sha256).hexdigest()
2727

2828

29-
def mailgun_sign_payload(data, api_key=TEST_API_KEY):
29+
def mailgun_sign_payload(data, webhook_signing_key=TEST_WEBHOOK_SIGNING_KEY):
3030
"""Add or complete Mailgun webhook signature block in data dict"""
3131
# Modifies the dict in place
3232
event_data = data.get('event-data', {})
3333
signature = data.setdefault('signature', {})
3434
token = signature.setdefault('token', '1234567890abcdef1234567890abcdef')
3535
timestamp = signature.setdefault('timestamp',
3636
str(int(float(event_data.get('timestamp', '1234567890.123')))))
37-
signature['signature'] = mailgun_signature(timestamp, token, api_key=api_key)
37+
signature['signature'] = mailgun_signature(timestamp, token, webhook_signing_key=webhook_signing_key)
3838
return data
3939

4040

41-
def mailgun_sign_legacy_payload(data, api_key=TEST_API_KEY):
41+
def mailgun_sign_legacy_payload(data, webhook_signing_key=TEST_WEBHOOK_SIGNING_KEY):
4242
"""Add a Mailgun webhook signature to data dict"""
4343
# Modifies the dict in place
4444
data.setdefault('timestamp', '1234567890')
4545
data.setdefault('token', '1234567890abcdef1234567890abcdef')
46-
data['signature'] = mailgun_signature(data['timestamp'], data['token'], api_key=api_key)
46+
data['signature'] = mailgun_signature(data['timestamp'], data['token'], webhook_signing_key=webhook_signing_key)
4747
return data
4848

4949

@@ -61,14 +61,44 @@ def querydict_to_postdict(qd):
6161

6262
@tag('mailgun')
6363
class MailgunWebhookSettingsTestCase(WebhookTestCase):
64-
def test_requires_api_key(self):
65-
with self.assertRaises(ImproperlyConfigured):
64+
def test_requires_webhook_signing_key(self):
65+
with self.assertRaisesMessage(ImproperlyConfigured, "MAILGUN_WEBHOOK_SIGNING_KEY"):
6666
self.client.post('/anymail/mailgun/tracking/', content_type="application/json",
6767
data=json.dumps(mailgun_sign_payload({'event-data': {'event': 'delivered'}})))
6868

69+
@override_settings(
70+
ANYMAIL_MAILGUN_API_KEY='TEST_API_KEY',
71+
ANYMAIL_MAILGUN_WEBHOOK_SIGNING_KEY='TEST_WEBHOOK_SIGNING_KEY',
72+
)
73+
def test_webhook_signing_is_different_from_api_key(self):
74+
"""Webhooks should use MAILGUN_WEBHOOK_SIGNING_KEY, not MAILGUN_API_KEY, if both provided"""
75+
payload = json.dumps(mailgun_sign_payload({'event-data': {'event': 'delivered'}},
76+
webhook_signing_key='TEST_WEBHOOK_SIGNING_KEY'))
77+
response = self.client.post('/anymail/mailgun/tracking/', content_type="application/json", data=payload)
78+
self.assertEqual(response.status_code, 200)
79+
80+
@override_settings(ANYMAIL_MAILGUN_API_KEY='TEST_API_KEY')
81+
def test_defaults_webhook_signing_to_api_key(self):
82+
"""Webhooks should default to MAILGUN_API_KEY if MAILGUN_WEBHOOK_SIGNING_KEY not provided"""
83+
payload = json.dumps(mailgun_sign_payload({'event-data': {'event': 'delivered'}},
84+
webhook_signing_key='TEST_API_KEY'))
85+
response = self.client.post('/anymail/mailgun/tracking/', content_type="application/json", data=payload)
86+
self.assertEqual(response.status_code, 200)
87+
88+
def test_webhook_signing_key_view_params(self):
89+
"""Webhook signing key can be provided as a view param"""
90+
view = MailgunTrackingWebhookView.as_view(webhook_signing_key='VIEW_SIGNING_KEY')
91+
view_instance = view.view_class(**view.view_initkwargs)
92+
self.assertEqual(view_instance.webhook_signing_key, b'VIEW_SIGNING_KEY')
93+
94+
# Can also use `api_key` param for backwards compatiblity with earlier Anymail versions
95+
view = MailgunTrackingWebhookView.as_view(api_key='VIEW_API_KEY')
96+
view_instance = view.view_class(**view.view_initkwargs)
97+
self.assertEqual(view_instance.webhook_signing_key, b'VIEW_API_KEY')
98+
6999

70100
@tag('mailgun')
71-
@override_settings(ANYMAIL_MAILGUN_API_KEY=TEST_API_KEY)
101+
@override_settings(ANYMAIL_MAILGUN_WEBHOOK_SIGNING_KEY=TEST_WEBHOOK_SIGNING_KEY)
72102
class MailgunWebhookSecurityTestCase(WebhookTestCase, WebhookBasicAuthTestsMixin):
73103
should_warn_if_no_auth = False # because we check webhook signature
74104

@@ -90,14 +120,14 @@ def test_verifies_missing_signature(self):
90120

91121
def test_verifies_bad_signature(self):
92122
data = mailgun_sign_payload({'event-data': {'event': 'delivered'}},
93-
api_key="wrong API key")
123+
webhook_signing_key="wrong signing key")
94124
response = self.client.post('/anymail/mailgun/tracking/', content_type="application/json",
95125
data=json.dumps(data))
96126
self.assertEqual(response.status_code, 400)
97127

98128

99129
@tag('mailgun')
100-
@override_settings(ANYMAIL_MAILGUN_API_KEY=TEST_API_KEY)
130+
@override_settings(ANYMAIL_MAILGUN_WEBHOOK_SIGNING_KEY=TEST_WEBHOOK_SIGNING_KEY)
101131
class MailgunTestCase(WebhookTestCase):
102132
# Tests for Mailgun's new webhooks (announced 2018-06-29)
103133

@@ -449,7 +479,7 @@ def test_clicked_event(self):
449479

450480

451481
@tag('mailgun')
452-
@override_settings(ANYMAIL_MAILGUN_API_KEY=TEST_API_KEY)
482+
@override_settings(ANYMAIL_MAILGUN_WEBHOOK_SIGNING_KEY=TEST_WEBHOOK_SIGNING_KEY)
453483
class MailgunLegacyTestCase(WebhookTestCase):
454484
# Tests for Mailgun's "legacy" webhooks
455485
# (which were the only webhooks available prior to Anymail 4.0)

0 commit comments

Comments
 (0)