Skip to content

Commit 4b2d6f8

Browse files
authored
SendGrid: Add optional webhook signature verification
Add optional signature verification for SendGrid tracking webhooks
1 parent b8a5ee8 commit 4b2d6f8

File tree

8 files changed

+615
-19
lines changed

8 files changed

+615
-19
lines changed

CHANGELOG.rst

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,24 @@ Release history
2525
^^^^^^^^^^^^^^^
2626
.. This extra heading level keeps the ToC from becoming unmanageably long
2727
28+
vNext
29+
-----
30+
31+
*unreleased changes*
32+
33+
Features
34+
~~~~~~~~
35+
36+
* **SendGrid:** Add optional signature verification for tracking webhooks.
37+
To support this, Anymail now includes the :pypi:`cryptography` package when
38+
installed with the ``django-anymail[sendgrid]`` extra.
39+
(Thanks to `@blag`_ for contributing this improvement. Note this was tested
40+
against SendGrid's live API by its contributor at the time it was added,
41+
but cannot be independently verified by Anymail's maintainers as we
42+
`no longer have access <https://github.com/anymail/django-anymail/issues/432>`__
43+
to a SendGrid test account.)
44+
45+
2846
v13.0.1
2947
-------
3048

@@ -1796,6 +1814,7 @@ Features
17961814
.. _@anstosa: https://github.com/anstosa
17971815
.. _@Arondit: https://github.com/Arondit
17981816
.. _@b0d0nne11: https://github.com/b0d0nne11
1817+
.. _@blag: https://github.com/blag
17991818
.. _@calvin: https://github.com/calvin
18001819
.. _@carrerasrodrigo: https://github.com/carrerasrodrigo
18011820
.. _@chickahoona: https://github.com/chickahoona

anymail/webhooks/base.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -125,11 +125,10 @@ def __init__(self, **kwargs):
125125
# no esp_name -- auth is shared between ESPs
126126
kwargs=kwargs,
127127
)
128-
129-
# Allow a single string:
130-
if isinstance(self.basic_auth, str):
128+
# Allow a single, non-empty string:
129+
if isinstance(self.basic_auth, str) and self.basic_auth:
131130
self.basic_auth = [self.basic_auth]
132-
if self.warn_if_no_basic_auth and len(self.basic_auth) < 1:
131+
if self.warn_if_no_basic_auth and not self.basic_auth:
133132
warnings.warn(
134133
"Your Anymail webhooks are insecure and open to anyone on the web. "
135134
"You should set WEBHOOK_SECRET in your ANYMAIL settings. "

anymail/webhooks/sendgrid.py

Lines changed: 123 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,19 @@
1+
from __future__ import annotations
2+
3+
import base64
4+
import binascii
15
import json
26
import warnings
37
from datetime import datetime, timezone
48
from email.parser import BytesParser
59
from email.policy import default as default_policy
610

7-
from ..exceptions import AnymailNotSupportedWarning
11+
from ..exceptions import (
12+
AnymailImproperlyInstalled,
13+
AnymailNotSupportedWarning,
14+
AnymailWebhookValidationFailure,
15+
_LazyError,
16+
)
817
from ..inbound import AnymailInboundMessage
918
from ..signals import (
1019
AnymailInboundEvent,
@@ -14,23 +23,122 @@
1423
inbound,
1524
tracking,
1625
)
26+
from ..utils import get_anymail_setting
1727
from .base import AnymailBaseWebhookView
1828

29+
try:
30+
from cryptography.exceptions import InvalidSignature
31+
from cryptography.hazmat.backends import default_backend
32+
from cryptography.hazmat.primitives import hashes, serialization
33+
from cryptography.hazmat.primitives.asymmetric import ec, types
34+
except ImportError:
35+
# This module gets imported by anymail.urls, so don't complain about cryptography
36+
# missing unless one of the SendGrid webhook views is actually used and needs it
37+
error = _LazyError(
38+
AnymailImproperlyInstalled(
39+
missing_package="cryptography", install_extra="sendgrid"
40+
)
41+
)
42+
serialization = error
43+
hashes = error
44+
default_backend = error
45+
ec = error
46+
InvalidSignature = Exception
1947

20-
class SendGridTrackingWebhookView(AnymailBaseWebhookView):
21-
"""Handler for SendGrid delivery and engagement tracking webhooks"""
2248

23-
esp_name = "SendGrid"
24-
signal = tracking
49+
class SendGridBaseWebhookView(AnymailBaseWebhookView):
50+
# Derived classes must set to name of webhook verification key setting
51+
# (lowercase, don't include esp_name).
52+
key_setting_name: str
53+
54+
# Loaded from key_setting_name; None -> signature verification is skipped
55+
webhook_verification_key: "types.PublicKeyTypes | None" = None
56+
57+
@classmethod
58+
def as_view(cls, **initkwargs):
59+
if not hasattr(cls, cls.key_setting_name):
60+
# The attribute must exist on the class before View.as_view
61+
# will allow overrides via kwarg
62+
setattr(cls, cls.key_setting_name, None)
63+
return super().as_view(**initkwargs)
2564

2665
def __init__(self, **kwargs):
66+
verification_key: str | None = get_anymail_setting(
67+
self.key_setting_name,
68+
esp_name=self.esp_name,
69+
default=None,
70+
kwargs=kwargs,
71+
allow_bare=True,
72+
)
73+
if verification_key:
74+
self.webhook_verification_key = serialization.load_pem_public_key(
75+
(
76+
"-----BEGIN PUBLIC KEY-----\n"
77+
+ verification_key
78+
+ "\n-----END PUBLIC KEY-----"
79+
).encode("utf-8"),
80+
backend=default_backend(),
81+
)
82+
if self.webhook_verification_key:
83+
# If the webhook key is successfully configured, then we don't need to warn about
84+
# missing basic auth
85+
self.warn_if_no_basic_auth = False
86+
else:
87+
# Purely defensive programming; should already be set to True
88+
self.warn_if_no_basic_auth = True
89+
2790
super().__init__(**kwargs)
2891
warnings.warn(
2992
"django-anymail has dropped official support for SendGrid."
3093
" See https://github.com/anymail/django-anymail/issues/432.",
3194
AnymailNotSupportedWarning,
3295
)
3396

97+
def validate_request(self, request):
98+
if self.webhook_verification_key:
99+
try:
100+
signature = request.headers["X-Twilio-Email-Event-Webhook-Signature"]
101+
except KeyError:
102+
raise AnymailWebhookValidationFailure(
103+
"X-Twilio-Email-Event-Webhook-Signature header missing from webhook"
104+
)
105+
try:
106+
timestamp = request.headers["X-Twilio-Email-Event-Webhook-Timestamp"]
107+
except KeyError:
108+
raise AnymailWebhookValidationFailure(
109+
"X-Twilio-Email-Event-Webhook-Timestamp header missing from webhook"
110+
)
111+
112+
timestamped_payload = timestamp.encode("utf-8") + request.body
113+
114+
try:
115+
decoded_signature = base64.b64decode(signature)
116+
self.webhook_verification_key.verify(
117+
decoded_signature,
118+
timestamped_payload,
119+
ec.ECDSA(hashes.SHA256()),
120+
)
121+
# We intentionally respond the same to both of these issues, see below
122+
# binascii.Error may come from attempting to base64-decode the signature
123+
# InvalidSignature will come from self.webhook_key.verify(...)
124+
except (binascii.Error, InvalidSignature):
125+
# We intentionally don't give out too much information here, because that would
126+
# make it easier used to characterize the issue and workaround the signature
127+
# verification
128+
setting_name = f"{self.esp_name}_{self.key_setting_name}".upper()
129+
raise AnymailWebhookValidationFailure(
130+
"SendGrid webhook called with incorrect signature"
131+
f" (check Anymail {setting_name} setting)"
132+
)
133+
134+
135+
class SendGridTrackingWebhookView(SendGridBaseWebhookView):
136+
"""Handler for SendGrid delivery and engagement tracking webhooks"""
137+
138+
esp_name = "SendGrid"
139+
key_setting_name = "tracking_webhook_verification_key"
140+
signal = tracking
141+
34142
def parse_events(self, request):
35143
esp_events = json.loads(request.body.decode("utf-8"))
36144
return [self.esp_to_anymail_event(esp_event) for esp_event in esp_events]
@@ -146,6 +254,16 @@ class SendGridInboundWebhookView(AnymailBaseWebhookView):
146254
esp_name = "SendGrid"
147255
signal = inbound
148256

257+
# The inbound webhook does not currently implement signature validation
258+
# because we don't have access to a SendGrid account that could test it.
259+
# It *should* only require changing to SendGridBaseWebhookView and
260+
# providing the setting name:
261+
#
262+
# class SendGridInboundWebhookView(SendGridBaseWebhookView):
263+
# key_setting_name = "inbound_webhook_verification_key"
264+
#
265+
# and then setting SENDGRID_INBOUND_WEBHOOK_VERIFICATION_KEY.
266+
149267
def __init__(self, **kwargs):
150268
super().__init__(**kwargs)
151269
warnings.warn(

docs/esps/sendgrid.rst

Lines changed: 85 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,31 @@ Anymail integrates with the Twilio `SendGrid`_ email service, using their `Web A
4545
.. _activity feed: https://app.sendgrid.com/email_activity?events=drops
4646

4747

48+
.. _sendgrid-installation:
49+
50+
Installation
51+
------------
52+
53+
Anymail optionally uses the :pypi:`cryptography` package to validate SendGrid webhook
54+
signatures. If you will use Anymail's :ref:`status tracking <event-tracking>` webhook
55+
with SendGrid signature verification, be sure to include the ``[sendgrid]`` option
56+
when you install Anymail:
57+
58+
.. code-block:: console
59+
60+
$ python -m pip install 'django-anymail[sendgrid]'
61+
62+
(Or separately run ``python -m pip install cryptography``.)
63+
64+
If you don't plan to use SendGrid signature verification, cryptography
65+
is not required. To avoid installing it, omit the ``[sendgrid]`` option.
66+
See :ref:`sendgrid-webhooks` below for details.
67+
68+
.. versionchanged:: vNext
69+
70+
Added cryptography to the ``[sendgrid]`` extras.
71+
72+
4873
Settings
4974
--------
5075

@@ -82,6 +107,27 @@ nor ``ANYMAIL_SENDGRID_API_KEY`` is set.
82107
.. _SendGrid API key settings: https://app.sendgrid.com/settings/api_keys
83108

84109

110+
.. setting:: ANYMAIL_SENDGRID_TRACKING_WEBHOOK_VERIFICATION_KEY
111+
112+
.. rubric:: SENDGRID_TRACKING_WEBHOOK_VERIFICATION_KEY
113+
114+
Optional additional public-key verification when using status tracking
115+
webhooks. See :ref:`sendgrid-webhooks` below.
116+
117+
This should be set to the verification key provided in the Event Webhook page
118+
of SendGrid Mail Settings.
119+
120+
.. code-block:: python
121+
122+
ANYMAIL = {
123+
...
124+
"SENDGRID_TRACKING_WEBHOOK_VERIFICATION_KEY": "A8f746...9fuVqQ==",
125+
}
126+
127+
(Note this works only with SendGrid's cryptographic signature verification,
128+
*not* their OAuth 2.0 option.)
129+
130+
85131
.. setting:: ANYMAIL_SENDGRID_GENERATE_MESSAGE_ID
86132

87133
.. rubric:: SENDGRID_GENERATE_MESSAGE_ID
@@ -407,13 +453,45 @@ in either Anymail settings or esp_extra as described above.)
407453
Status tracking webhooks
408454
------------------------
409455

410-
If you are using Anymail's normalized :ref:`status tracking <event-tracking>`, enter
411-
the url in your `SendGrid mail settings`_, under "Event Notification":
456+
Anymail's normalized :ref:`status tracking <event-tracking>` works
457+
with SendGrid's webhooks.
412458

413-
:samp:`https://{random}:{random}@{yoursite.example.com}/anymail/sendgrid/tracking/`
459+
SendGrid optionally provides webhook signature verification. You have three
460+
choices for securing the status tracking webhook:
414461

415-
* *random:random* is an :setting:`ANYMAIL_WEBHOOK_SECRET` shared secret
416-
* *yoursite.example.com* is your Django site
462+
* Use SendGrid's signature verification: follow their
463+
`Event Webhook Security Features`_ documentation and set
464+
:setting:`SENDGRID_TRACKING_WEBHOOK_VERIFICATION_KEY <ANYMAIL_SENDGRID_TRACKING_WEBHOOK_VERIFICATION_KEY>`
465+
(requires the :pypi:`cryptography` package---see :ref:`sendgrid-installation`)
466+
* Use Anymail's shared secret validation, by setting
467+
:setting:`WEBHOOK_SECRET <ANYMAIL_WEBHOOK_SECRET>`
468+
(does not require cryptography)
469+
* Use both
470+
471+
Signature verification is recommended, unless you do not want to add
472+
cryptography to your dependencies.
473+
474+
.. versionchanged:: vNext
475+
476+
Added support for SendGrid webhook signature verification.
477+
(Earlier releases supported only shared secret validation.)
478+
479+
.. _Event Webhook Security Features:
480+
https://www.twilio.com/docs/sendgrid/for-developers/tracking-events/getting-started-event-webhook-security-features#the-signed-event-webhook
481+
482+
483+
To configure Anymail status tracking for SendGrid, enter one of these urls
484+
in your `SendGrid mail settings`_ under "Event Notification" (substituting
485+
your Django site for *yoursite.example.com*):
486+
487+
* If you are *not* using Anymail's shared webhook secret:
488+
489+
:samp:`https://{yoursite.example.com}/anymail/sendgrid/tracking/`
490+
491+
* Or if you *are* using Anymail's :setting:`WEBHOOK_SECRET <ANYMAIL_WEBHOOK_SECRET>`,
492+
include the *random:random* shared secret in the URL:
493+
494+
:samp:`https://{random}:{random}@{yoursite.example.com}/sendgrid/tracking/`
417495

418496
Be sure to check the boxes in the SendGrid settings for the event types you want to receive.
419497

@@ -446,6 +524,8 @@ The Destination URL setting will be:
446524
* *random:random* is an :setting:`ANYMAIL_WEBHOOK_SECRET` shared secret
447525
* *yoursite.example.com* is your Django site
448526

527+
(Anymail does not currently support signature verification for the inbound parse webhook.)
528+
449529
You should enable SendGrid's "POST the raw, full MIME message" checkbox (see note below).
450530
And be sure the URL has a trailing slash. (SendGrid's inbound processing won't follow Django's
451531
:setting:`APPEND_SLASH` redirect.)

pyproject.toml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,13 @@ mailjet = []
7979
mandrill = []
8080
postmark = []
8181
resend = ["svix"]
82-
sendgrid = []
82+
sendgrid = [
83+
# SendGrid optionally uses cryptography for verifying webhook signatures.
84+
# Cryptography's wheels are broken on darwin-arm64 before Python 3.9,
85+
# and unbuildable on PyPy 3.8 due to PyO3 limitations. Since cpython 3.8
86+
# has also passed EOL, just require Python 3.9+ with SendGrid.
87+
"cryptography; python_version >= '3.9'"
88+
]
8389
sendinblue = []
8490
sparkpost = []
8591
unisender-go = []

0 commit comments

Comments
 (0)