|
| 1 | +from __future__ import annotations |
| 2 | + |
| 3 | +import base64 |
| 4 | +import binascii |
1 | 5 | import json
|
2 | 6 | import warnings
|
3 | 7 | from datetime import datetime, timezone
|
4 | 8 | from email.parser import BytesParser
|
5 | 9 | from email.policy import default as default_policy
|
6 | 10 |
|
7 |
| -from ..exceptions import AnymailNotSupportedWarning |
| 11 | +from ..exceptions import ( |
| 12 | + AnymailImproperlyInstalled, |
| 13 | + AnymailNotSupportedWarning, |
| 14 | + AnymailWebhookValidationFailure, |
| 15 | + _LazyError, |
| 16 | +) |
8 | 17 | from ..inbound import AnymailInboundMessage
|
9 | 18 | from ..signals import (
|
10 | 19 | AnymailInboundEvent,
|
|
14 | 23 | inbound,
|
15 | 24 | tracking,
|
16 | 25 | )
|
| 26 | +from ..utils import get_anymail_setting |
17 | 27 | from .base import AnymailBaseWebhookView
|
18 | 28 |
|
| 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 |
19 | 47 |
|
20 |
| -class SendGridTrackingWebhookView(AnymailBaseWebhookView): |
21 |
| - """Handler for SendGrid delivery and engagement tracking webhooks""" |
22 | 48 |
|
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) |
25 | 64 |
|
26 | 65 | 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 | + |
27 | 90 | super().__init__(**kwargs)
|
28 | 91 | warnings.warn(
|
29 | 92 | "django-anymail has dropped official support for SendGrid."
|
30 | 93 | " See https://github.com/anymail/django-anymail/issues/432.",
|
31 | 94 | AnymailNotSupportedWarning,
|
32 | 95 | )
|
33 | 96 |
|
| 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 | + |
34 | 142 | def parse_events(self, request):
|
35 | 143 | esp_events = json.loads(request.body.decode("utf-8"))
|
36 | 144 | return [self.esp_to_anymail_event(esp_event) for esp_event in esp_events]
|
@@ -146,6 +254,16 @@ class SendGridInboundWebhookView(AnymailBaseWebhookView):
|
146 | 254 | esp_name = "SendGrid"
|
147 | 255 | signal = inbound
|
148 | 256 |
|
| 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 | + |
149 | 267 | def __init__(self, **kwargs):
|
150 | 268 | super().__init__(**kwargs)
|
151 | 269 | warnings.warn(
|
|
0 commit comments