Files
django-anymail/tests/webhook_cases.py
medmunds 1a6086f2b5 Security: rename WEBHOOK_AUTHORIZATION --> WEBHOOK_SECRET
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
2018-02-08 11:38:15 -08:00

134 lines
5.1 KiB
Python

import base64
from django.test import override_settings, SimpleTestCase
from mock import create_autospec, ANY
from anymail.exceptions import AnymailInsecureWebhookWarning
from anymail.signals import tracking, inbound
from .utils import AnymailTestMixin, ClientWithCsrfChecks
def event_handler(sender, event, esp_name, **kwargs):
"""Prototypical webhook signal handler"""
pass
@override_settings(ANYMAIL={'WEBHOOK_SECRET': 'username:password'})
class WebhookTestCase(AnymailTestMixin, SimpleTestCase):
"""Base for testing webhooks
- connects webhook signal handlers
- sets up basic auth by default (since most ESP webhooks warn if it's not enabled)
"""
client_class = ClientWithCsrfChecks
def setUp(self):
super(WebhookTestCase, self).setUp()
# Use correct basic auth by default (individual tests can override):
self.set_basic_auth()
# Install mocked signal handlers
self.tracking_handler = create_autospec(event_handler)
tracking.connect(self.tracking_handler)
self.addCleanup(tracking.disconnect, self.tracking_handler)
self.inbound_handler = create_autospec(event_handler)
inbound.connect(self.inbound_handler)
self.addCleanup(inbound.disconnect, self.inbound_handler)
def set_basic_auth(self, username='username', password='password'):
"""Set basic auth for all subsequent test client requests"""
credentials = base64.b64encode("{}:{}".format(username, password).encode('utf-8')).decode('utf-8')
self.client.defaults['HTTP_AUTHORIZATION'] = "Basic {}".format(credentials)
def clear_basic_auth(self):
self.client.defaults.pop('HTTP_AUTHORIZATION', None)
def assert_handler_called_once_with(self, mockfn, *expected_args, **expected_kwargs):
"""Verifies mockfn was called with expected_args and at least expected_kwargs.
Ignores *additional* actual kwargs (which might be added by Django signal dispatch).
(This differs from mock.assert_called_once_with.)
Returns the actual kwargs.
"""
self.assertEqual(mockfn.call_count, 1)
actual_args, actual_kwargs = mockfn.call_args
self.assertEqual(actual_args, expected_args)
for key, expected_value in expected_kwargs.items():
if expected_value is ANY:
self.assertIn(key, actual_kwargs)
else:
self.assertEqual(actual_kwargs[key], expected_value)
return actual_kwargs
def get_kwargs(self, mockfn):
"""Return the kwargs passed to the most recent call to mockfn"""
self.assertIsNotNone(mockfn.call_args) # mockfn hasn't been called yet
actual_args, actual_kwargs = mockfn.call_args
return actual_kwargs
# noinspection PyUnresolvedReferences
class WebhookBasicAuthTestsMixin(object):
"""Common test cases for webhook basic authentication.
Instantiate for each ESP's webhooks by:
- mixing into WebhookTestCase
- defining call_webhook to invoke the ESP's webhook
"""
should_warn_if_no_auth = True # subclass set False if other webhook verification used
def call_webhook(self):
# Concrete test cases should call a webhook via self.client.post,
# and return the response
raise NotImplementedError()
@override_settings(ANYMAIL={}) # Clear the WEBHOOK_AUTH settings from superclass
def test_warns_if_no_auth(self):
if self.should_warn_if_no_auth:
with self.assertWarns(AnymailInsecureWebhookWarning):
response = self.call_webhook()
else:
with self.assertDoesNotWarn(AnymailInsecureWebhookWarning):
response = self.call_webhook()
self.assertEqual(response.status_code, 200)
def test_verifies_basic_auth(self):
response = self.call_webhook()
self.assertEqual(response.status_code, 200)
def test_verifies_bad_auth(self):
self.set_basic_auth('baduser', 'wrongpassword')
response = self.call_webhook()
self.assertEqual(response.status_code, 400)
def test_verifies_missing_auth(self):
self.clear_basic_auth()
response = self.call_webhook()
self.assertEqual(response.status_code, 400)
@override_settings(ANYMAIL={'WEBHOOK_SECRET': ['cred1:pass1', 'cred2:pass2']})
def test_supports_credential_rotation(self):
"""You can supply a list of basic auth credentials, and any is allowed"""
self.set_basic_auth('cred1', 'pass1')
response = self.call_webhook()
self.assertEqual(response.status_code, 200)
self.set_basic_auth('cred2', 'pass2')
response = self.call_webhook()
self.assertEqual(response.status_code, 200)
self.set_basic_auth('baduser', 'wrongpassword')
response = self.call_webhook()
self.assertEqual(response.status_code, 400)
@override_settings(ANYMAIL={'WEBHOOK_AUTHORIZATION': "username:password"})
def test_deprecated_setting(self):
"""The older WEBHOOK_AUTHORIZATION setting is still supported (for now)"""
response = self.call_webhook()
self.assertEqual(response.status_code, 200)