Add MAILGUN_WEBHOOK_SIGNING_KEY setting.

Fixes #153.
This commit is contained in:
medmunds
2019-07-07 13:43:08 -07:00
parent fe6ee5bf84
commit 181d5886eb
5 changed files with 122 additions and 29 deletions

View File

@@ -25,6 +25,22 @@ Release history
^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^
.. This extra heading level keeps the ToC from becoming unmanageably long .. This extra heading level keeps the ToC from becoming unmanageably long
vNext
-----
*UNRELEASED*
Fixes
~~~~~
* **Mailgun:** Add new `MAILGUN_WEBHOOK_SIGNING_KEY` setting for verifying tracking and
inbound webhook calls. Mailgun's webhook signing key can become different from your
`MAILGUN_API_KEY` if you have ever rotated either key.
See `docs <https://anymail.readthedocs.io/en/latest/esps/mailgun/#std:setting-ANYMAIL_MAILGUN_WEBHOOK_SIGNING_KEY>`__.
(More in `#153`_. Thanks to `@dominik-lekse`_ for reporting the problem and Mailgun's
`@mbk-ok`_ for identifying the cause.)
v6.0.1 v6.0.1
------ ------
@@ -945,17 +961,20 @@ Features
.. _#115: https://github.com/anymail/issues/115 .. _#115: https://github.com/anymail/issues/115
.. _#147: https://github.com/anymail/issues/147 .. _#147: https://github.com/anymail/issues/147
.. _#148: https://github.com/anymail/issues/148 .. _#148: https://github.com/anymail/issues/148
.. _#153: https://github.com/anymail/issues/153
.. _@ailionx: https://github.com/ailionx .. _@ailionx: https://github.com/ailionx
.. _@calvin: https://github.com/calvin .. _@calvin: https://github.com/calvin
.. _@costela: https://github.com/costela .. _@costela: https://github.com/costela
.. _@decibyte: https://github.com/decibyte .. _@decibyte: https://github.com/decibyte
.. _@dominik-lekse: https://github.com/dominik-lekse
.. _@ewingrj: https://github.com/ewingrj .. _@ewingrj: https://github.com/ewingrj
.. _@fdemmer: https://github.com/fdemmer .. _@fdemmer: https://github.com/fdemmer
.. _@janneThoft: https://github.com/janneThoft .. _@janneThoft: https://github.com/janneThoft
.. _@joshkersey: https://github.com/joshkersey .. _@joshkersey: https://github.com/joshkersey
.. _@Lekensteyn: https://github.com/Lekensteyn .. _@Lekensteyn: https://github.com/Lekensteyn
.. _@lewistaylor: https://github.com/lewistaylor .. _@lewistaylor: https://github.com/lewistaylor
.. _@mbk-ok: https://github.com/mbk-ok
.. _@RignonNoel: https://github.com/RignonNoel .. _@RignonNoel: https://github.com/RignonNoel
.. _@sebbacon: https://github.com/sebbacon .. _@sebbacon: https://github.com/sebbacon
.. _@varche1: https://github.com/varche1 .. _@varche1: https://github.com/varche1

View File

@@ -10,7 +10,7 @@ from .base import AnymailBaseWebhookView
from ..exceptions import AnymailConfigurationError, AnymailWebhookValidationFailure, AnymailInvalidAddress from ..exceptions import AnymailConfigurationError, AnymailWebhookValidationFailure, AnymailInvalidAddress
from ..inbound import AnymailInboundMessage from ..inbound import AnymailInboundMessage
from ..signals import inbound, tracking, AnymailInboundEvent, AnymailTrackingEvent, EventType, RejectReason from ..signals import inbound, tracking, AnymailInboundEvent, AnymailTrackingEvent, EventType, RejectReason
from ..utils import get_anymail_setting, combine, querydict_getfirst, parse_single_address from ..utils import get_anymail_setting, combine, querydict_getfirst, parse_single_address, UNSET
class MailgunBaseWebhookView(AnymailBaseWebhookView): class MailgunBaseWebhookView(AnymailBaseWebhookView):
@@ -19,12 +19,18 @@ class MailgunBaseWebhookView(AnymailBaseWebhookView):
esp_name = "Mailgun" esp_name = "Mailgun"
warn_if_no_basic_auth = False # because we validate against signature warn_if_no_basic_auth = False # because we validate against signature
webhook_signing_key = None # (Declaring class attr allows override by kwargs in View.as_view.)
# The `api_key` attribute name is still allowed for compatibility with earlier Anymail releases.
api_key = None # (Declaring class attr allows override by kwargs in View.as_view.) api_key = None # (Declaring class attr allows override by kwargs in View.as_view.)
def __init__(self, **kwargs): def __init__(self, **kwargs):
# webhook_signing_key: falls back to api_key if webhook_signing_key not provided
api_key = get_anymail_setting('api_key', esp_name=self.esp_name, api_key = get_anymail_setting('api_key', esp_name=self.esp_name,
kwargs=kwargs, allow_bare=True) kwargs=kwargs, allow_bare=True, default=None)
self.api_key = api_key.encode('ascii') # hmac.new requires bytes key in python 3 webhook_signing_key = get_anymail_setting('webhook_signing_key', esp_name=self.esp_name,
kwargs=kwargs, default=UNSET if api_key is None else api_key)
self.webhook_signing_key = webhook_signing_key.encode('ascii') # hmac.new requires bytes key in python 3
super(MailgunBaseWebhookView, self).__init__(**kwargs) super(MailgunBaseWebhookView, self).__init__(**kwargs)
def validate_request(self, request): def validate_request(self, request):
@@ -52,7 +58,7 @@ class MailgunBaseWebhookView(AnymailBaseWebhookView):
except KeyError: except KeyError:
raise AnymailWebhookValidationFailure("Mailgun webhook called without required security fields") raise AnymailWebhookValidationFailure("Mailgun webhook called without required security fields")
expected_signature = hmac.new(key=self.api_key, msg='{}{}'.format(timestamp, token).encode('ascii'), expected_signature = hmac.new(key=self.webhook_signing_key, msg='{}{}'.format(timestamp, token).encode('ascii'),
digestmod=hashlib.sha256).hexdigest() digestmod=hashlib.sha256).hexdigest()
if not constant_time_compare(signature, expected_signature): if not constant_time_compare(signature, expected_signature):
raise AnymailWebhookValidationFailure("Mailgun webhook called with incorrect signature") raise AnymailWebhookValidationFailure("Mailgun webhook called with incorrect signature")

View File

@@ -26,7 +26,8 @@ in your settings.py.
.. rubric:: MAILGUN_API_KEY .. rubric:: MAILGUN_API_KEY
Required. Your Mailgun API key: Required for sending. Your Mailgun "Private API key" from the Mailgun
`API security settings`_:
.. code-block:: python .. code-block:: python
@@ -54,6 +55,27 @@ Mailgun sender domain, this setting is not needed.
See :ref:`mailgun-sender-domain` below for examples. See :ref:`mailgun-sender-domain` below for examples.
.. setting:: ANYMAIL_MAILGUN_WEBHOOK_SIGNING_KEY
.. rubric:: MAILGUN_WEBHOOK_SIGNING_KEY
.. versionadded:: 6.1
Required for tracking or inbound webhooks. Your "HTTP webhook signing key" from the
Mailgun `API security settings`_:
.. code-block:: python
ANYMAIL = {
...
"MAILGUN_WEBHOOK_SIGNING_KEY": "<your webhook signing key>",
}
If not provided, Anymail will attempt to validate webhooks using the
:setting:`MAILGUN_API_KEY <ANYMAIL_MAILGUN_API_KEY>` setting instead. (These two keys have
the same values for new Mailgun users, but will diverge if you ever rotate either key.)
.. setting:: ANYMAIL_MAILGUN_API_URL .. setting:: ANYMAIL_MAILGUN_API_URL
.. rubric:: MAILGUN_API_URL .. rubric:: MAILGUN_API_URL
@@ -75,6 +97,9 @@ region:
} }
.. _API security settings: https://app.mailgun.com/app/account/security/api_keys
.. _mailgun-sender-domain: .. _mailgun-sender-domain:
Email sender domain Email sender domain
@@ -260,9 +285,14 @@ Status tracking webhooks
Added support for Mailgun's June, 2018 (non-"legacy") webhook format. Added support for Mailgun's June, 2018 (non-"legacy") webhook format.
.. versionchanged:: 6.1
Added support for a new :setting:`MAILGUN_WEBHOOK_SIGNING_KEY <ANYMAIL_MAILGUN_WEBHOOK_SIGNING_KEY>`
setting, separate from your MAILGUN_API_KEY.
If you are using Anymail's normalized :ref:`status tracking <event-tracking>`, enter If you are using Anymail's normalized :ref:`status tracking <event-tracking>`, enter
the url in the `Mailgun webhooks dashboard`_. (Be sure to select the correct sending the url in the Mailgun webhooks config for your domain. (Be sure to select the correct
domain---Mailgun's sandbox and production domains have separate webhook settings.) sending domain---Mailgun's sandbox and production domains have separate webhook settings.)
Mailgun allows you to enter a different URL for each event type: just enter this same Mailgun allows you to enter a different URL for each event type: just enter this same
Anymail tracking URL for all events you want to receive: Anymail tracking URL for all events you want to receive:
@@ -273,8 +303,9 @@ Anymail tracking URL for all events you want to receive:
* *yoursite.example.com* is your Django site * *yoursite.example.com* is your Django site
Mailgun implements a limited form of webhook signing, and Anymail will verify Mailgun implements a limited form of webhook signing, and Anymail will verify
these signatures (based on your :setting:`MAILGUN_API_KEY <ANYMAIL_MAILGUN_API_KEY>` these signatures against your
Anymail setting). By default, Mailgun's webhook signature provides similar security :setting:`MAILGUN_WEBHOOK_SIGNING_KEY <ANYMAIL_MAILGUN_WEBHOOK_SIGNING_KEY>`
Anymail setting. By default, Mailgun's webhook signature provides similar security
to Anymail's shared webhook secret, so it's acceptable to omit the to Anymail's shared webhook secret, so it's acceptable to omit the
:setting:`ANYMAIL_WEBHOOK_SECRET` setting (and "{random}:{random}@" portion of the :setting:`ANYMAIL_WEBHOOK_SECRET` setting (and "{random}:{random}@" portion of the
webhook url) with Mailgun webhooks. webhook url) with Mailgun webhooks.
@@ -321,7 +352,6 @@ Mailgun's other event APIs.)
newer, non-legacy webhooks.) newer, non-legacy webhooks.)
.. _Mailgun webhooks dashboard: https://mailgun.com/app/webhooks
.. _Mailgun webhook payload: https://documentation.mailgun.com/en/latest/user_manual.html#webhooks .. _Mailgun webhook payload: https://documentation.mailgun.com/en/latest/user_manual.html#webhooks
@@ -333,7 +363,7 @@ Inbound webhook
If you want to receive email from Mailgun through Anymail's normalized :ref:`inbound <inbound>` If you want to receive email from Mailgun through Anymail's normalized :ref:`inbound <inbound>`
handling, follow Mailgun's `Receiving, Storing and Fowarding Messages`_ guide to set up handling, follow Mailgun's `Receiving, Storing and Fowarding Messages`_ guide to set up
an inbound route that forwards to Anymail's inbound webhook. (You can configure routes an inbound route that forwards to Anymail's inbound webhook. (You can configure routes
using Mailgun's API, or simply using the `Mailgun routes dashboard`_.) using Mailgun's API, or simply using the `Mailgun receiving config`_.)
The *action* for your route will be either: The *action* for your route will be either:
@@ -352,9 +382,17 @@ received email (including complex forms like multi-message mailing list digests)
If you want to use Anymail's normalized :attr:`~anymail.inbound.AnymailInboundMessage.spam_detected` and If you want to use Anymail's normalized :attr:`~anymail.inbound.AnymailInboundMessage.spam_detected` and
:attr:`~anymail.inbound.AnymailInboundMessage.spam_score` attributes, you'll need to set your Mailgun :attr:`~anymail.inbound.AnymailInboundMessage.spam_score` attributes, you'll need to set your Mailgun
domain's inbound spam filter to "Deliver spam, but add X-Mailgun-SFlag and X-Mailgun-SScore headers" domain's inbound spam filter to "Deliver spam, but add X-Mailgun-SFlag and X-Mailgun-SScore headers"
(in the `Mailgun domains dashboard`_). (in the `Mailgun domains config`_).
Anymail will verify Mailgun inbound message events using your
:setting:`MAILGUN_WEBHOOK_SIGNING_KEY <ANYMAIL_MAILGUN_WEBHOOK_SIGNING_KEY>`
Anymail setting. By default, Mailgun's webhook signature provides similar security
to Anymail's shared webhook secret, so it's acceptable to omit the
:setting:`ANYMAIL_WEBHOOK_SECRET` setting (and "{random}:{random}@" portion of the
action) with Mailgun inbound routing.
.. _Receiving, Storing and Fowarding Messages: .. _Receiving, Storing and Fowarding Messages:
https://documentation.mailgun.com/en/latest/user_manual.html#receiving-forwarding-and-storing-messages https://documentation.mailgun.com/en/latest/user_manual.html#receiving-forwarding-and-storing-messages
.. _Mailgun routes dashboard: https://app.mailgun.com/app/routes .. _Mailgun receiving config: https://app.mailgun.com/app/receiving/routes
.. _Mailgun domains dashboard: https://app.mailgun.com/app/domains .. _Mailgun domains config: https://app.mailgun.com/app/sending/domains

View File

@@ -13,14 +13,14 @@ from anymail.signals import AnymailInboundEvent
from anymail.webhooks.mailgun import MailgunInboundWebhookView from anymail.webhooks.mailgun import MailgunInboundWebhookView
from .test_mailgun_webhooks import ( from .test_mailgun_webhooks import (
TEST_API_KEY, mailgun_sign_payload, TEST_WEBHOOK_SIGNING_KEY, mailgun_sign_payload,
mailgun_sign_legacy_payload, querydict_to_postdict) mailgun_sign_legacy_payload, querydict_to_postdict)
from .utils import sample_image_content, sample_email_content from .utils import sample_image_content, sample_email_content
from .webhook_cases import WebhookTestCase from .webhook_cases import WebhookTestCase
@tag('mailgun') @tag('mailgun')
@override_settings(ANYMAIL_MAILGUN_API_KEY=TEST_API_KEY) @override_settings(ANYMAIL_MAILGUN_WEBHOOK_SIGNING_KEY=TEST_WEBHOOK_SIGNING_KEY)
class MailgunInboundTestCase(WebhookTestCase): class MailgunInboundTestCase(WebhookTestCase):
def test_inbound_basics(self): def test_inbound_basics(self):
raw_event = mailgun_sign_legacy_payload({ raw_event = mailgun_sign_legacy_payload({

View File

@@ -14,19 +14,19 @@ from anymail.webhooks.mailgun import MailgunTrackingWebhookView
from .webhook_cases import WebhookTestCase, WebhookBasicAuthTestsMixin from .webhook_cases import WebhookTestCase, WebhookBasicAuthTestsMixin
TEST_API_KEY = 'TEST_API_KEY' TEST_WEBHOOK_SIGNING_KEY = 'TEST_WEBHOOK_SIGNING_KEY'
def mailgun_signature(timestamp, token, api_key): def mailgun_signature(timestamp, token, webhook_signing_key):
"""Generates a Mailgun webhook signature""" """Generates a Mailgun webhook signature"""
# https://documentation.mailgun.com/en/latest/user_manual.html#securing-webhooks # https://documentation.mailgun.com/en/latest/user_manual.html#securing-webhooks
return hmac.new( return hmac.new(
key=api_key.encode('ascii'), key=webhook_signing_key.encode('ascii'),
msg='{timestamp}{token}'.format(timestamp=timestamp, token=token).encode('ascii'), msg='{timestamp}{token}'.format(timestamp=timestamp, token=token).encode('ascii'),
digestmod=hashlib.sha256).hexdigest() digestmod=hashlib.sha256).hexdigest()
def mailgun_sign_payload(data, api_key=TEST_API_KEY): def mailgun_sign_payload(data, webhook_signing_key=TEST_WEBHOOK_SIGNING_KEY):
"""Add or complete Mailgun webhook signature block in data dict""" """Add or complete Mailgun webhook signature block in data dict"""
# Modifies the dict in place # Modifies the dict in place
event_data = data.get('event-data', {}) event_data = data.get('event-data', {})
@@ -34,16 +34,16 @@ def mailgun_sign_payload(data, api_key=TEST_API_KEY):
token = signature.setdefault('token', '1234567890abcdef1234567890abcdef') token = signature.setdefault('token', '1234567890abcdef1234567890abcdef')
timestamp = signature.setdefault('timestamp', timestamp = signature.setdefault('timestamp',
str(int(float(event_data.get('timestamp', '1234567890.123'))))) str(int(float(event_data.get('timestamp', '1234567890.123')))))
signature['signature'] = mailgun_signature(timestamp, token, api_key=api_key) signature['signature'] = mailgun_signature(timestamp, token, webhook_signing_key=webhook_signing_key)
return data return data
def mailgun_sign_legacy_payload(data, api_key=TEST_API_KEY): def mailgun_sign_legacy_payload(data, webhook_signing_key=TEST_WEBHOOK_SIGNING_KEY):
"""Add a Mailgun webhook signature to data dict""" """Add a Mailgun webhook signature to data dict"""
# Modifies the dict in place # Modifies the dict in place
data.setdefault('timestamp', '1234567890') data.setdefault('timestamp', '1234567890')
data.setdefault('token', '1234567890abcdef1234567890abcdef') data.setdefault('token', '1234567890abcdef1234567890abcdef')
data['signature'] = mailgun_signature(data['timestamp'], data['token'], api_key=api_key) data['signature'] = mailgun_signature(data['timestamp'], data['token'], webhook_signing_key=webhook_signing_key)
return data return data
@@ -61,14 +61,44 @@ def querydict_to_postdict(qd):
@tag('mailgun') @tag('mailgun')
class MailgunWebhookSettingsTestCase(WebhookTestCase): class MailgunWebhookSettingsTestCase(WebhookTestCase):
def test_requires_api_key(self): def test_requires_webhook_signing_key(self):
with self.assertRaises(ImproperlyConfigured): with self.assertRaisesMessage(ImproperlyConfigured, "MAILGUN_WEBHOOK_SIGNING_KEY"):
self.client.post('/anymail/mailgun/tracking/', content_type="application/json", self.client.post('/anymail/mailgun/tracking/', content_type="application/json",
data=json.dumps(mailgun_sign_payload({'event-data': {'event': 'delivered'}}))) data=json.dumps(mailgun_sign_payload({'event-data': {'event': 'delivered'}})))
@override_settings(
ANYMAIL_MAILGUN_API_KEY='TEST_API_KEY',
ANYMAIL_MAILGUN_WEBHOOK_SIGNING_KEY='TEST_WEBHOOK_SIGNING_KEY',
)
def test_webhook_signing_is_different_from_api_key(self):
"""Webhooks should use MAILGUN_WEBHOOK_SIGNING_KEY, not MAILGUN_API_KEY, if both provided"""
payload = json.dumps(mailgun_sign_payload({'event-data': {'event': 'delivered'}},
webhook_signing_key='TEST_WEBHOOK_SIGNING_KEY'))
response = self.client.post('/anymail/mailgun/tracking/', content_type="application/json", data=payload)
self.assertEqual(response.status_code, 200)
@override_settings(ANYMAIL_MAILGUN_API_KEY='TEST_API_KEY')
def test_defaults_webhook_signing_to_api_key(self):
"""Webhooks should default to MAILGUN_API_KEY if MAILGUN_WEBHOOK_SIGNING_KEY not provided"""
payload = json.dumps(mailgun_sign_payload({'event-data': {'event': 'delivered'}},
webhook_signing_key='TEST_API_KEY'))
response = self.client.post('/anymail/mailgun/tracking/', content_type="application/json", data=payload)
self.assertEqual(response.status_code, 200)
def test_webhook_signing_key_view_params(self):
"""Webhook signing key can be provided as a view param"""
view = MailgunTrackingWebhookView.as_view(webhook_signing_key='VIEW_SIGNING_KEY')
view_instance = view.view_class(**view.view_initkwargs)
self.assertEqual(view_instance.webhook_signing_key, b'VIEW_SIGNING_KEY')
# Can also use `api_key` param for backwards compatiblity with earlier Anymail versions
view = MailgunTrackingWebhookView.as_view(api_key='VIEW_API_KEY')
view_instance = view.view_class(**view.view_initkwargs)
self.assertEqual(view_instance.webhook_signing_key, b'VIEW_API_KEY')
@tag('mailgun') @tag('mailgun')
@override_settings(ANYMAIL_MAILGUN_API_KEY=TEST_API_KEY) @override_settings(ANYMAIL_MAILGUN_WEBHOOK_SIGNING_KEY=TEST_WEBHOOK_SIGNING_KEY)
class MailgunWebhookSecurityTestCase(WebhookTestCase, WebhookBasicAuthTestsMixin): class MailgunWebhookSecurityTestCase(WebhookTestCase, WebhookBasicAuthTestsMixin):
should_warn_if_no_auth = False # because we check webhook signature should_warn_if_no_auth = False # because we check webhook signature
@@ -90,14 +120,14 @@ class MailgunWebhookSecurityTestCase(WebhookTestCase, WebhookBasicAuthTestsMixin
def test_verifies_bad_signature(self): def test_verifies_bad_signature(self):
data = mailgun_sign_payload({'event-data': {'event': 'delivered'}}, data = mailgun_sign_payload({'event-data': {'event': 'delivered'}},
api_key="wrong API key") webhook_signing_key="wrong signing key")
response = self.client.post('/anymail/mailgun/tracking/', content_type="application/json", response = self.client.post('/anymail/mailgun/tracking/', content_type="application/json",
data=json.dumps(data)) data=json.dumps(data))
self.assertEqual(response.status_code, 400) self.assertEqual(response.status_code, 400)
@tag('mailgun') @tag('mailgun')
@override_settings(ANYMAIL_MAILGUN_API_KEY=TEST_API_KEY) @override_settings(ANYMAIL_MAILGUN_WEBHOOK_SIGNING_KEY=TEST_WEBHOOK_SIGNING_KEY)
class MailgunTestCase(WebhookTestCase): class MailgunTestCase(WebhookTestCase):
# Tests for Mailgun's new webhooks (announced 2018-06-29) # Tests for Mailgun's new webhooks (announced 2018-06-29)
@@ -449,7 +479,7 @@ class MailgunTestCase(WebhookTestCase):
@tag('mailgun') @tag('mailgun')
@override_settings(ANYMAIL_MAILGUN_API_KEY=TEST_API_KEY) @override_settings(ANYMAIL_MAILGUN_WEBHOOK_SIGNING_KEY=TEST_WEBHOOK_SIGNING_KEY)
class MailgunLegacyTestCase(WebhookTestCase): class MailgunLegacyTestCase(WebhookTestCase):
# Tests for Mailgun's "legacy" webhooks # Tests for Mailgun's "legacy" webhooks
# (which were the only webhooks available prior to Anymail 4.0) # (which were the only webhooks available prior to Anymail 4.0)