diff --git a/anymail/apps.py b/anymail/apps.py index ab19c6e..72a7408 100644 --- a/anymail/apps.py +++ b/anymail/apps.py @@ -1,4 +1,7 @@ from django.apps import AppConfig +from django.core import checks + +from .checks import check_deprecated_settings class AnymailBaseConfig(AppConfig): @@ -6,4 +9,4 @@ class AnymailBaseConfig(AppConfig): verbose_name = "Anymail" def ready(self): - pass + checks.register(check_deprecated_settings) diff --git a/anymail/checks.py b/anymail/checks.py new file mode 100644 index 0000000..3c54e3b --- /dev/null +++ b/anymail/checks.py @@ -0,0 +1,24 @@ +from django.conf import settings +from django.core import checks + + +def check_deprecated_settings(app_configs, **kwargs): + errors = [] + + anymail_settings = getattr(settings, "ANYMAIL", {}) + + # anymail.W001: rename WEBHOOK_AUTHORIZATION to WEBHOOK_SECRET + if "WEBHOOK_AUTHORIZATION" in anymail_settings: + errors.append(checks.Warning( + "The ANYMAIL setting 'WEBHOOK_AUTHORIZATION' has been renamed 'WEBHOOK_SECRET' to improve security.", + hint="You must update your settings.py. The old name will stop working in a near-future release.", + id="anymail.W001", + )) + if hasattr(settings, "ANYMAIL_WEBHOOK_AUTHORIZATION"): + errors.append(checks.Warning( + "The ANYMAIL_WEBHOOK_AUTHORIZATION setting has been renamed ANYMAIL_WEBHOOK_SECRET to improve security.", + hint="You must update your settings.py. The old name will stop working in a near-future release.", + id="anymail.W001", + )) + + return errors diff --git a/anymail/webhooks/base.py b/anymail/webhooks/base.py index 2bfd36e..30207e7 100644 --- a/anymail/webhooks/base.py +++ b/anymail/webhooks/base.py @@ -24,15 +24,19 @@ class AnymailBasicAuthMixin(object): 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_authorization', default=[], + 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_AUTHORIZATION in your ANYMAIL settings. " + "You should set WEBHOOK_SECRET in your ANYMAIL settings. " "See 'Securing webhooks' in the Anymail docs.", AnymailInsecureWebhookWarning) # noinspection PyArgumentList diff --git a/docs/esps/mailgun.rst b/docs/esps/mailgun.rst index 3802e50..1ba3e4d 100644 --- a/docs/esps/mailgun.rst +++ b/docs/esps/mailgun.rst @@ -197,7 +197,7 @@ for all events you want to receive: :samp:`https://{random}:{random}@{yoursite.example.com}/anymail/mailgun/tracking/` - * *random:random* is an :setting:`ANYMAIL_WEBHOOK_AUTHORIZATION` shared secret + * *random:random* is an :setting:`ANYMAIL_WEBHOOK_SECRET` shared secret * *yoursite.example.com* is your Django site If you use multiple Mailgun sending domains, you'll need to enter the webhook @@ -232,7 +232,7 @@ The *action* for your route will be either: :samp:`forward("https://{random}:{random}@{yoursite.example.com}/anymail/mailgun/inbound/")` :samp:`forward("https://{random}:{random}@{yoursite.example.com}/anymail/mailgun/inbound_mime/")` - * *random:random* is an :setting:`ANYMAIL_WEBHOOK_AUTHORIZATION` shared secret + * *random:random* is an :setting:`ANYMAIL_WEBHOOK_SECRET` shared secret * *yoursite.example.com* is your Django site Anymail accepts either of Mailgun's "fully-parsed" (.../inbound/) and "raw MIME" (.../inbound_mime/) diff --git a/docs/esps/mailjet.rst b/docs/esps/mailjet.rst index 06b8784..1853a2b 100644 --- a/docs/esps/mailjet.rst +++ b/docs/esps/mailjet.rst @@ -232,7 +232,7 @@ the url in your Mailjet account REST API settings under `Event tracking (trigger :samp:`https://{random}:{random}@{yoursite.example.com}/anymail/mailjet/tracking/` - * *random:random* is an :setting:`ANYMAIL_WEBHOOK_AUTHORIZATION` shared secret + * *random:random* is an :setting:`ANYMAIL_WEBHOOK_SECRET` shared secret * *yoursite.example.com* is your Django site Be sure to enter the URL in the Mailjet settings for all the event types you want to receive. @@ -263,7 +263,7 @@ The parseroute Url parameter will be: :samp:`https://{random}:{random}@{yoursite.example.com}/anymail/mailjet/inbound/` - * *random:random* is an :setting:`ANYMAIL_WEBHOOK_AUTHORIZATION` shared secret + * *random:random* is an :setting:`ANYMAIL_WEBHOOK_SECRET` shared secret * *yoursite.example.com* is your Django site Once you've done Mailjet's "basic setup" to configure the Parse API webhook, you can skip diff --git a/docs/esps/mandrill.rst b/docs/esps/mandrill.rst index d3ac498..b074d62 100644 --- a/docs/esps/mandrill.rst +++ b/docs/esps/mandrill.rst @@ -206,7 +206,7 @@ requires deploying your Django project twice: :samp:`https://{random}:{random}@{yoursite.example.com}/anymail/mandrill/` - * *random:random* is an :setting:`ANYMAIL_WEBHOOK_AUTHORIZATION` shared secret + * *random:random* is an :setting:`ANYMAIL_WEBHOOK_SECRET` shared secret * *yoursite.example.com* is your Django site * (Note: Unlike Anymail's other supported ESPs, the Mandrill webhook uses this single url for both tracking and inbound events.) diff --git a/docs/esps/postmark.rst b/docs/esps/postmark.rst index 7ec25d7..da0fe73 100644 --- a/docs/esps/postmark.rst +++ b/docs/esps/postmark.rst @@ -181,7 +181,7 @@ want to receive all these types of events): :samp:`https://{random}:{random}@{yoursite.example.com}/anymail/postmark/tracking/` - * *random:random* is an :setting:`ANYMAIL_WEBHOOK_AUTHORIZATION` shared secret + * *random:random* is an :setting:`ANYMAIL_WEBHOOK_SECRET` shared secret * *yoursite.example.com* is your Django site Anymail doesn't care about the "include bounce content" and "post only on first open" @@ -216,7 +216,7 @@ The InboundHookUrl setting will be: :samp:`https://{random}:{random}@{yoursite.example.com}/anymail/postmark/inbound/` - * *random:random* is an :setting:`ANYMAIL_WEBHOOK_AUTHORIZATION` shared secret + * *random:random* is an :setting:`ANYMAIL_WEBHOOK_SECRET` shared secret * *yoursite.example.com* is your Django site Anymail handles the "parse an email" part of Postmark's instructions for you, but you'll diff --git a/docs/esps/sendgrid.rst b/docs/esps/sendgrid.rst index 7a1708c..ddd6a2c 100644 --- a/docs/esps/sendgrid.rst +++ b/docs/esps/sendgrid.rst @@ -284,7 +284,7 @@ the url in your `SendGrid mail settings`_, under "Event Notification": :samp:`https://{random}:{random}@{yoursite.example.com}/anymail/sendgrid/tracking/` - * *random:random* is an :setting:`ANYMAIL_WEBHOOK_AUTHORIZATION` shared secret + * *random:random* is an :setting:`ANYMAIL_WEBHOOK_SECRET` shared secret * *yoursite.example.com* is your Django site Be sure to check the boxes in the SendGrid settings for the event types you want to receive. @@ -315,7 +315,7 @@ The Destination URL setting will be: :samp:`https://{random}:{random}@{yoursite.example.com}/anymail/sendgrid/inbound/` - * *random:random* is an :setting:`ANYMAIL_WEBHOOK_AUTHORIZATION` shared secret + * *random:random* is an :setting:`ANYMAIL_WEBHOOK_SECRET` shared secret * *yoursite.example.com* is your Django site Be sure the URL has a trailing slash. (SendGrid's inbound processing won't follow Django's diff --git a/docs/esps/sparkpost.rst b/docs/esps/sparkpost.rst index 47ad8ed..7913245 100644 --- a/docs/esps/sparkpost.rst +++ b/docs/esps/sparkpost.rst @@ -197,7 +197,7 @@ webhook in your `SparkPost account settings under "Webhooks"`_: * Target URL: :samp:`https://{yoursite.example.com}/anymail/sparkpost/tracking/` * Authentication: choose "Basic Auth." For username and password enter the two halves of the - *random:random* shared secret you created for your :setting:`ANYMAIL_WEBHOOK_AUTHORIZATION` + *random:random* shared secret you created for your :setting:`ANYMAIL_WEBHOOK_SECRET` Django setting. (Anymail doesn't support OAuth webhook auth.) * Events: click "Select" and then *clear* the checkbox for "Relay Events" category (which is for inbound email). You can leave all the other categories of events checked, or disable @@ -235,7 +235,7 @@ The target parameter for the Relay Webhook will be: :samp:`https://{random}:{random}@{yoursite.example.com}/anymail/sparkpost/inbound/` - * *random:random* is an :setting:`ANYMAIL_WEBHOOK_AUTHORIZATION` shared secret + * *random:random* is an :setting:`ANYMAIL_WEBHOOK_SECRET` shared secret * *yoursite.example.com* is your Django site .. _Enabling Inbound Email Relaying: diff --git a/docs/installation.rst b/docs/installation.rst index fff5a18..803d3ff 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -98,7 +98,7 @@ Skip this section if you won't be using Anymail's webhooks. or subject your app to malicious input data. At a minimum, your site should **use https** and you should - configure **webhook authorization** as described below. + configure a **webhook secret** as described below. See :ref:`securing-webhooks` for additional information. @@ -106,14 +106,14 @@ Skip this section if you won't be using Anymail's webhooks. If you want to use Anymail's inbound or tracking webhooks: 1. In your :file:`settings.py`, add - :setting:`WEBHOOK_AUTHORIZATION ` + :setting:`WEBHOOK_SECRET ` to the ``ANYMAIL`` block: .. code-block:: python ANYMAIL = { ... - 'WEBHOOK_AUTHORIZATION': ':', + 'WEBHOOK_SECRET': ':', } This setting should be a string with two sequences of random characters, @@ -133,7 +133,7 @@ If you want to use Anymail's inbound or tracking webhooks: (This setting is actually an HTTP basic auth string. You can also set it to a list of auth strings, to simplify credential rotation or use different auth - with different ESPs. See :setting:`ANYMAIL_WEBHOOK_AUTHORIZATION` in the + with different ESPs. See :setting:`ANYMAIL_WEBHOOK_SECRET` in the :ref:`securing-webhooks` docs for more details.) @@ -160,7 +160,7 @@ If you want to use Anymail's inbound or tracking webhooks: :samp:`https://{random}:{random}@{yoursite.example.com}/anymail/{esp}/{type}/` * "https" (rather than http) is *strongly recommended* - * *random:random* is the WEBHOOK_AUTHORIZATION string you created in step 1 + * *random:random* is the WEBHOOK_SECRET string you created in step 1 * *yoursite.example.com* is your Django site * "anymail" is the url prefix (from step 2) * *esp* is the lowercase name of your ESP (e.g., "sendgrid" or "mailgun") @@ -266,7 +266,7 @@ Set to `True` to ignore these problems and send the email anyway. See :ref:`unsupported-features`. (Default `False`.) -.. rubric:: WEBHOOK_AUTHORIZATION +.. rubric:: WEBHOOK_SECRET A `'random:random'` shared secret string. Anymail will reject incoming webhook calls from your ESP that don't include this authorization. You can also give a list of @@ -274,12 +274,18 @@ shared secret strings, and Anymail will allow ESP webhook calls that match any o (to facilitate credential rotation). See :ref:`securing-webhooks`. Default is unset, which leaves your webhooks insecure. Anymail -will warn if you try to use webhooks with setting up authorization. +will warn if you try to use webhooks without a shared secret. This is actually implemented using HTTP basic authorization, and the string is technically a "username:password" format. But you should *not* use any real username or password for this shared secret. +.. versionchanged:: 1.4 + + The earlier WEBHOOK_AUTHORIZATION setting was renamed WEBHOOK_SECRET, so that + Django error reporting sanitizes it. The old name is still allowed in v1.4, + but will be removed in a near-future release. You should update your settings. + .. setting:: ANYMAIL_REQUESTS_TIMEOUT diff --git a/docs/tips/securing_webhooks.rst b/docs/tips/securing_webhooks.rst index ac8399f..61e8329 100644 --- a/docs/tips/securing_webhooks.rst +++ b/docs/tips/securing_webhooks.rst @@ -29,7 +29,7 @@ If you aren't able to use https on your Django site, then you should not set up your ESP's webhooks. -.. setting:: ANYMAIL_WEBHOOK_AUTHORIZATION +.. setting:: ANYMAIL_WEBHOOK_SECRET Use a shared authorization secret --------------------------------- @@ -41,7 +41,7 @@ with webhook data, to prove the post is coming from your ESP. Most ESPs recommend using HTTP basic authorization as this shared secret. Anymail includes support for this, via the -:setting:`!ANYMAIL_WEBHOOK_AUTHORIZATION` setting. +:setting:`!ANYMAIL_WEBHOOK_SECRET` setting. Basic usage is covered in the :ref:`webhooks configuration ` docs. @@ -60,7 +60,7 @@ any of the authorization strings: ANYMAIL = { ... - 'WEBHOOK_AUTHORIZATION': [ + 'WEBHOOK_SECRET': [ 'abcdefghijklmnop:qrstuvwxyz0123456789', 'ZYXWVUTSRQPONMLK:JIHGFEDCBA9876543210', ], diff --git a/tests/test_checks.py b/tests/test_checks.py new file mode 100644 index 0000000..8d31534 --- /dev/null +++ b/tests/test_checks.py @@ -0,0 +1,27 @@ +from django.core import checks +from django.test import SimpleTestCase +from django.test.utils import override_settings + +from anymail.checks import check_deprecated_settings + +from .utils import AnymailTestMixin + + +class DeprecatedSettingsTests(SimpleTestCase, AnymailTestMixin): + @override_settings(ANYMAIL={"WEBHOOK_AUTHORIZATION": "abcde:12345"}) + def test_webhook_authorization(self): + errors = check_deprecated_settings(None) + self.assertEqual(errors, [checks.Warning( + "The ANYMAIL setting 'WEBHOOK_AUTHORIZATION' has been renamed 'WEBHOOK_SECRET' to improve security.", + hint="You must update your settings.py. The old name will stop working in a near-future release.", + id="anymail.W001", + )]) + + @override_settings(ANYMAIL_WEBHOOK_AUTHORIZATION="abcde:12345", ANYMAIL={}) + def test_anymail_webhook_authorization(self): + errors = check_deprecated_settings(None) + self.assertEqual(errors, [checks.Warning( + "The ANYMAIL_WEBHOOK_AUTHORIZATION setting has been renamed ANYMAIL_WEBHOOK_SECRET to improve security.", + hint="You must update your settings.py. The old name will stop working in a near-future release.", + id="anymail.W001", + )]) diff --git a/tests/test_mandrill_webhooks.py b/tests/test_mandrill_webhooks.py index 1fc19f3..0dd2a5f 100644 --- a/tests/test_mandrill_webhooks.py +++ b/tests/test_mandrill_webhooks.py @@ -87,7 +87,7 @@ class MandrillWebhookSecurityTestCase(WebhookTestCase, WebhookBasicAuthTestsMixi response = self.client.post(**kwargs) self.assertEqual(response.status_code, 400) - @override_settings(ANYMAIL={}) # clear WEBHOOK_AUTHORIZATION from WebhookTestCase + @override_settings(ANYMAIL={}) # clear WEBHOOK_SECRET from WebhookTestCase def test_no_basic_auth(self): # Signature validation should work properly if you're not using basic auth self.clear_basic_auth() @@ -99,7 +99,7 @@ class MandrillWebhookSecurityTestCase(WebhookTestCase, WebhookBasicAuthTestsMixi ALLOWED_HOSTS=['127.0.0.1', '.example.com'], ANYMAIL={ "MANDRILL_WEBHOOK_URL": "https://abcde:12345@example.com/anymail/mandrill/", - "WEBHOOK_AUTHORIZATION": "abcde:12345", + "WEBHOOK_SECRET": "abcde:12345", }) def test_webhook_url_setting(self): # If Django can't build_absolute_uri correctly (e.g., because your proxy @@ -111,7 +111,7 @@ class MandrillWebhookSecurityTestCase(WebhookTestCase, WebhookBasicAuthTestsMixi self.assertEqual(response.status_code, 200) # override WebhookBasicAuthTestsMixin version of this test - @override_settings(ANYMAIL={'WEBHOOK_AUTHORIZATION': ['cred1:pass1', 'cred2:pass2']}) + @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') diff --git a/tests/webhook_cases.py b/tests/webhook_cases.py index 249bf19..0b8d0fb 100644 --- a/tests/webhook_cases.py +++ b/tests/webhook_cases.py @@ -14,7 +14,7 @@ def event_handler(sender, event, esp_name, **kwargs): pass -@override_settings(ANYMAIL={'WEBHOOK_AUTHORIZATION': 'username:password'}) +@override_settings(ANYMAIL={'WEBHOOK_SECRET': 'username:password'}) class WebhookTestCase(AnymailTestMixin, SimpleTestCase): """Base for testing webhooks @@ -111,7 +111,7 @@ class WebhookBasicAuthTestsMixin(object): response = self.call_webhook() self.assertEqual(response.status_code, 400) - @override_settings(ANYMAIL={'WEBHOOK_AUTHORIZATION': ['cred1:pass1', 'cred2:pass2']}) + @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') @@ -125,3 +125,9 @@ class WebhookBasicAuthTestsMixin(object): 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)