mirror of
https://github.com/pacnpal/django-anymail.git
synced 2025-12-20 11:51:05 -05:00
This fixes a low severity security issue affecting Anymail v0.2--v1.3. Django error reporting includes the value of your Anymail WEBHOOK_AUTHORIZATION setting. In a properly-configured deployment, this should not be cause for concern. But if you have somehow exposed your Django error reports (e.g., by mis-deploying with DEBUG=True or by sending error reports through insecure channels), anyone who gains access to those reports could discover your webhook shared secret. An attacker could use this to post fabricated or malicious Anymail tracking/inbound events to your app, if you are using those Anymail features. The fix renames Anymail's webhook shared secret setting so that Django's error reporting mechanism will [sanitize][0] it. If you are using Anymail's event tracking and/or inbound webhooks, you should upgrade to this release and change "WEBHOOK_AUTHORIZATION" to "WEBHOOK_SECRET" in the ANYMAIL section of your settings.py. You may also want to [rotate the shared secret][1] value, particularly if you have ever exposed your Django error reports to untrusted individuals. If you are only using Anymail's EmailBackends for sending email and have not set up Anymail's webhooks, this issue does not affect you. The old WEBHOOK_AUTHORIZATION setting is still allowed in this release, but will issue a system-check warning when running most Django management commands. It will be removed completely in a near-future release, as a breaking change. Thanks to Charlie DeTar (@yourcelf) for responsibly reporting this security issue through private channels. [0]: https://docs.djangoproject.com/en/stable/ref/settings/#debug [1]: https://anymail.readthedocs.io/en/1.4/tips/securing_webhooks/#use-a-shared-authorization-secret
149 lines
6.3 KiB
Python
149 lines
6.3 KiB
Python
import warnings
|
|
|
|
import six
|
|
from django.http import HttpResponse
|
|
from django.utils.crypto import constant_time_compare
|
|
from django.utils.decorators import method_decorator
|
|
from django.views.decorators.csrf import csrf_exempt
|
|
from django.views.generic import View
|
|
|
|
from ..exceptions import AnymailInsecureWebhookWarning, AnymailWebhookValidationFailure
|
|
from ..utils import get_anymail_setting, collect_all_methods, get_request_basic_auth
|
|
|
|
|
|
class AnymailBasicAuthMixin(object):
|
|
"""Implements webhook basic auth as mixin to AnymailBaseWebhookView."""
|
|
|
|
# Whether to warn if basic auth is not configured.
|
|
# For most ESPs, basic auth is the only webhook security,
|
|
# so the default is True. Subclasses can set False if
|
|
# they enforce other security (like signed webhooks).
|
|
warn_if_no_basic_auth = True
|
|
|
|
# List of allowable HTTP basic-auth 'user:pass' strings.
|
|
basic_auth = None # (Declaring class attr allows override by kwargs in View.as_view.)
|
|
|
|
def __init__(self, **kwargs):
|
|
self.basic_auth = get_anymail_setting('webhook_secret', default=[],
|
|
kwargs=kwargs) # no esp_name -- auth is shared between ESPs
|
|
if not self.basic_auth:
|
|
# Temporarily allow deprecated WEBHOOK_AUTHORIZATION setting
|
|
self.basic_auth = get_anymail_setting('webhook_authorization', default=[], kwargs=kwargs)
|
|
|
|
# Allow a single string:
|
|
if isinstance(self.basic_auth, six.string_types):
|
|
self.basic_auth = [self.basic_auth]
|
|
if self.warn_if_no_basic_auth and len(self.basic_auth) < 1:
|
|
warnings.warn(
|
|
"Your Anymail webhooks are insecure and open to anyone on the web. "
|
|
"You should set WEBHOOK_SECRET in your ANYMAIL settings. "
|
|
"See 'Securing webhooks' in the Anymail docs.",
|
|
AnymailInsecureWebhookWarning)
|
|
# noinspection PyArgumentList
|
|
super(AnymailBasicAuthMixin, self).__init__(**kwargs)
|
|
|
|
def validate_request(self, request):
|
|
"""If configured for webhook basic auth, validate request has correct auth."""
|
|
if self.basic_auth:
|
|
request_auth = get_request_basic_auth(request)
|
|
# Use constant_time_compare to avoid timing attack on basic auth. (It's OK that any()
|
|
# can terminate early: we're not trying to protect how many auth strings are allowed,
|
|
# just the contents of each individual auth string.)
|
|
auth_ok = any(constant_time_compare(request_auth, allowed_auth)
|
|
for allowed_auth in self.basic_auth)
|
|
if not auth_ok:
|
|
# noinspection PyUnresolvedReferences
|
|
raise AnymailWebhookValidationFailure(
|
|
"Missing or invalid basic auth in Anymail %s webhook" % self.esp_name)
|
|
|
|
|
|
# Mixin note: Django's View.__init__ doesn't cooperate with chaining,
|
|
# so all mixins that need __init__ must appear before View in MRO.
|
|
class AnymailBaseWebhookView(AnymailBasicAuthMixin, View):
|
|
"""Base view for processing ESP event webhooks
|
|
|
|
ESP-specific implementations should subclass
|
|
and implement parse_events. They may also
|
|
want to implement validate_request
|
|
if additional security is available.
|
|
"""
|
|
|
|
def __init__(self, **kwargs):
|
|
super(AnymailBaseWebhookView, self).__init__(**kwargs)
|
|
self.validators = collect_all_methods(self.__class__, 'validate_request')
|
|
|
|
# Subclass implementation:
|
|
|
|
# Where to send events: either ..signals.inbound or ..signals.tracking
|
|
signal = None
|
|
|
|
def validate_request(self, request):
|
|
"""Check validity of webhook post, or raise AnymailWebhookValidationFailure.
|
|
|
|
AnymailBaseWebhookView includes basic auth validation.
|
|
Subclasses can implement (or provide via mixins) if the ESP supports
|
|
additional validation (such as signature checking).
|
|
|
|
*All* definitions of this method in the class chain (including mixins)
|
|
will be called. There is no need to chain to the superclass.
|
|
(See self.run_validators and collect_all_methods.)
|
|
|
|
Security note: use django.utils.crypto.constant_time_compare for string
|
|
comparisons, to avoid exposing your validation to a timing attack.
|
|
"""
|
|
# if not constant_time_compare(request.POST['signature'], expected_signature):
|
|
# raise AnymailWebhookValidationFailure("...message...")
|
|
# (else just do nothing)
|
|
pass
|
|
|
|
def parse_events(self, request):
|
|
"""Return a list of normalized AnymailWebhookEvent extracted from ESP post data.
|
|
|
|
Subclasses must implement.
|
|
"""
|
|
raise NotImplementedError()
|
|
|
|
# HTTP handlers (subclasses shouldn't need to override):
|
|
|
|
http_method_names = ["post", "head", "options"]
|
|
|
|
@method_decorator(csrf_exempt)
|
|
def dispatch(self, request, *args, **kwargs):
|
|
return super(AnymailBaseWebhookView, self).dispatch(request, *args, **kwargs)
|
|
|
|
def head(self, request, *args, **kwargs):
|
|
# Some ESPs verify the webhook with a HEAD request at configuration time
|
|
return HttpResponse()
|
|
|
|
def post(self, request, *args, **kwargs):
|
|
# Normal Django exception handling will do the right thing:
|
|
# - AnymailWebhookValidationFailure will turn into an HTTP 400 response
|
|
# (via Django SuspiciousOperation handling)
|
|
# - Any other errors (e.g., in signal dispatch) will turn into HTTP 500
|
|
# responses (via normal Django error handling). ESPs generally
|
|
# treat that as "try again later".
|
|
self.run_validators(request)
|
|
events = self.parse_events(request)
|
|
esp_name = self.esp_name
|
|
for event in events:
|
|
self.signal.send(sender=self.__class__, event=event, esp_name=esp_name)
|
|
return HttpResponse()
|
|
|
|
# Request validation (subclasses shouldn't need to override):
|
|
|
|
def run_validators(self, request):
|
|
for validator in self.validators:
|
|
validator(self, request)
|
|
|
|
@property
|
|
def esp_name(self):
|
|
"""
|
|
Read-only name of the ESP for this webhook view.
|
|
|
|
Subclasses must override with class attr. E.g.:
|
|
esp_name = "Postmark"
|
|
esp_name = "SendGrid" # (use ESP's preferred capitalization)
|
|
"""
|
|
raise NotImplementedError("%s.%s must declare esp_name class attr" %
|
|
(self.__class__.__module__, self.__class__.__name__))
|