From f0693f6f91600561f527a4262092b7ab96401e4c Mon Sep 17 00:00:00 2001 From: Rohan Kapoor Date: Tue, 30 Oct 2018 04:12:41 -0700 Subject: [PATCH] Switch mailgun webhooks to the new Mailgun webhook api (#17919) * Switch mailgun webhooks to the webhook api * Change mailgun strings to indicate application/json is in use * Lint * Revert Changes to .translations. * Don't fail if the API key isn't set --- homeassistant/components/mailgun/__init__.py | 46 +++- homeassistant/components/mailgun/strings.json | 2 +- tests/components/mailgun/test_init.py | 230 ++++++++++++++++-- 3 files changed, 252 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/mailgun/__init__.py b/homeassistant/components/mailgun/__init__.py index e52bc663c9a..e78dc0aa479 100644 --- a/homeassistant/components/mailgun/__init__.py +++ b/homeassistant/components/mailgun/__init__.py @@ -4,6 +4,10 @@ Support for Mailgun. For more details about this component, please refer to the documentation at https://home-assistant.io/components/mailgun/ """ +import hashlib +import hmac +import json +import logging import voluptuous as vol @@ -12,7 +16,7 @@ from homeassistant.const import CONF_API_KEY, CONF_DOMAIN, CONF_WEBHOOK_ID from homeassistant.helpers import config_entry_flow DOMAIN = 'mailgun' -API_PATH = '/api/{}'.format(DOMAIN) +_LOGGER = logging.getLogger(__name__) DEPENDENCIES = ['webhook'] MESSAGE_RECEIVED = '{}_message_received'.format(DOMAIN) CONF_SANDBOX = 'sandbox' @@ -38,9 +42,40 @@ async def async_setup(hass, config): async def handle_webhook(hass, webhook_id, request): """Handle incoming webhook with Mailgun inbound messages.""" - data = dict(await request.post()) - data['webhook_id'] = webhook_id - hass.bus.async_fire(MESSAGE_RECEIVED, data) + body = await request.text() + try: + data = json.loads(body) if body else {} + except ValueError: + return None + + if isinstance(data, dict) and 'signature' in data.keys(): + if await verify_webhook(hass, **data['signature']): + data['webhook_id'] = webhook_id + hass.bus.async_fire(MESSAGE_RECEIVED, data) + return + + _LOGGER.warning( + 'Mailgun webhook received an unauthenticated message - webhook_id: %s', + webhook_id + ) + + +async def verify_webhook(hass, token=None, timestamp=None, signature=None): + """Verify webhook was signed by Mailgun.""" + if DOMAIN not in hass.data: + _LOGGER.warning('Cannot validate Mailgun webhook, missing API Key') + return True + + if not (token and timestamp and signature): + return False + + hmac_digest = hmac.new( + key=bytes(hass.data[DOMAIN][CONF_API_KEY], 'utf-8'), + msg=bytes('{}{}'.format(timestamp, token), 'utf-8'), + digestmod=hashlib.sha256 + ).hexdigest() + + return hmac.compare_digest(signature, hmac_digest) async def async_setup_entry(hass, entry): @@ -59,8 +94,7 @@ config_entry_flow.register_webhook_flow( DOMAIN, 'Mailgun Webhook', { - 'mailgun_url': - 'https://www.mailgun.com/blog/a-guide-to-using-mailguns-webhooks', + 'mailgun_url': 'https://documentation.mailgun.com/en/latest/user_manual.html#webhooks', # noqa: E501 pylint: disable=line-too-long 'docs_url': 'https://www.home-assistant.io/components/mailgun/' } ) diff --git a/homeassistant/components/mailgun/strings.json b/homeassistant/components/mailgun/strings.json index 0e993bef5d4..c72ec747b30 100644 --- a/homeassistant/components/mailgun/strings.json +++ b/homeassistant/components/mailgun/strings.json @@ -12,7 +12,7 @@ "not_internet_accessible": "Your Home Assistant instance needs to be accessible from the internet to receive Mailgun messages." }, "create_entry": { - "default": "To send events to Home Assistant, you will need to setup [Webhooks with Mailgun]({mailgun_url}).\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/x-www-form-urlencoded\n\nSee [the documentation]({docs_url}) on how to configure automations to handle incoming data." + "default": "To send events to Home Assistant, you will need to setup [Webhooks with Mailgun]({mailgun_url}).\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/json\n\nSee [the documentation]({docs_url}) on how to configure automations to handle incoming data." } } } diff --git a/tests/components/mailgun/test_init.py b/tests/components/mailgun/test_init.py index 312e3e22bfd..6e84c68e980 100644 --- a/tests/components/mailgun/test_init.py +++ b/tests/components/mailgun/test_init.py @@ -1,39 +1,231 @@ """Test the init file of Mailgun.""" -from unittest.mock import patch +import hashlib +import hmac +from unittest.mock import Mock + +import pytest from homeassistant import data_entry_flow -from homeassistant.components import mailgun - +from homeassistant.components import mailgun, webhook +from homeassistant.const import CONF_API_KEY, CONF_DOMAIN from homeassistant.core import callback +from homeassistant.setup import async_setup_component + +API_KEY = 'abc123' -async def test_config_flow_registers_webhook(hass, aiohttp_client): - """Test setting up Mailgun and sending webhook.""" - with patch('homeassistant.util.get_local_ip', return_value='example.com'): - result = await hass.config_entries.flow.async_init('mailgun', context={ - 'source': 'user' - }) +@pytest.fixture +async def http_client(hass, aiohttp_client): + """Initialize a Home Assistant Server for testing this module.""" + await async_setup_component(hass, webhook.DOMAIN, {}) + return await aiohttp_client(hass.http.app) + + +@pytest.fixture +async def webhook_id_with_api_key(hass): + """Initialize the Mailgun component and get the webhook_id.""" + await async_setup_component(hass, mailgun.DOMAIN, { + mailgun.DOMAIN: { + CONF_API_KEY: API_KEY, + CONF_DOMAIN: 'example.com' + }, + }) + + hass.config.api = Mock(base_url='http://example.com') + result = await hass.config_entries.flow.async_init('mailgun', context={ + 'source': 'user' + }) assert result['type'] == data_entry_flow.RESULT_TYPE_FORM, result result = await hass.config_entries.flow.async_configure( result['flow_id'], {}) assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - webhook_id = result['result'].data['webhook_id'] - mailgun_events = [] + return result['result'].data['webhook_id'] + + +@pytest.fixture +async def webhook_id_without_api_key(hass): + """Initialize the Mailgun component and get the webhook_id w/o API key.""" + await async_setup_component(hass, mailgun.DOMAIN, {}) + + hass.config.api = Mock(base_url='http://example.com') + result = await hass.config_entries.flow.async_init('mailgun', context={ + 'source': 'user' + }) + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM, result + + result = await hass.config_entries.flow.async_configure( + result['flow_id'], {}) + assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + + return result['result'].data['webhook_id'] + + +@pytest.fixture +async def mailgun_events(hass): + """Return a list of mailgun_events triggered.""" + events = [] @callback def handle_event(event): """Handle Mailgun event.""" - mailgun_events.append(event) + events.append(event) hass.bus.async_listen(mailgun.MESSAGE_RECEIVED, handle_event) - client = await aiohttp_client(hass.http.app) - await client.post('/api/webhook/{}'.format(webhook_id), data={ - 'hello': 'mailgun' - }) + return events - assert len(mailgun_events) == 1 - assert mailgun_events[0].data['webhook_id'] == webhook_id - assert mailgun_events[0].data['hello'] == 'mailgun' + +async def test_mailgun_webhook_with_missing_signature( + http_client, + webhook_id_with_api_key, + mailgun_events +): + """Test that webhook doesn't trigger an event without a signature.""" + event_count = len(mailgun_events) + + await http_client.post( + '/api/webhook/{}'.format(webhook_id_with_api_key), + json={ + 'hello': 'mailgun', + 'signature': {} + } + ) + + assert len(mailgun_events) == event_count + + await http_client.post( + '/api/webhook/{}'.format(webhook_id_with_api_key), + json={ + 'hello': 'mailgun', + } + ) + + assert len(mailgun_events) == event_count + + +async def test_mailgun_webhook_with_different_api_key( + http_client, + webhook_id_with_api_key, + mailgun_events +): + """Test that webhook doesn't trigger an event with a wrong signature.""" + timestamp = '1529006854' + token = 'a8ce0edb2dd8301dee6c2405235584e45aa91d1e9f979f3de0' + + event_count = len(mailgun_events) + + await http_client.post( + '/api/webhook/{}'.format(webhook_id_with_api_key), + json={ + 'hello': 'mailgun', + 'signature': { + 'signature': hmac.new( + key=b'random_api_key', + msg=bytes('{}{}'.format(timestamp, token), 'utf-8'), + digestmod=hashlib.sha256 + ).hexdigest(), + 'timestamp': timestamp, + 'token': token + } + } + ) + + assert len(mailgun_events) == event_count + + +async def test_mailgun_webhook_event_with_correct_api_key( + http_client, + webhook_id_with_api_key, + mailgun_events +): + """Test that webhook triggers an event after validating a signature.""" + timestamp = '1529006854' + token = 'a8ce0edb2dd8301dee6c2405235584e45aa91d1e9f979f3de0' + + event_count = len(mailgun_events) + + await http_client.post( + '/api/webhook/{}'.format(webhook_id_with_api_key), + json={ + 'hello': 'mailgun', + 'signature': { + 'signature': hmac.new( + key=bytes(API_KEY, 'utf-8'), + msg=bytes('{}{}'.format(timestamp, token), 'utf-8'), + digestmod=hashlib.sha256 + ).hexdigest(), + 'timestamp': timestamp, + 'token': token + } + } + ) + + assert len(mailgun_events) == event_count + 1 + assert mailgun_events[-1].data['webhook_id'] == webhook_id_with_api_key + assert mailgun_events[-1].data['hello'] == 'mailgun' + + +async def test_mailgun_webhook_with_missing_signature_without_api_key( + http_client, + webhook_id_without_api_key, + mailgun_events +): + """Test that webhook triggers an event without a signature w/o API key.""" + event_count = len(mailgun_events) + + await http_client.post( + '/api/webhook/{}'.format(webhook_id_without_api_key), + json={ + 'hello': 'mailgun', + 'signature': {} + } + ) + + assert len(mailgun_events) == event_count + 1 + assert mailgun_events[-1].data['webhook_id'] == webhook_id_without_api_key + assert mailgun_events[-1].data['hello'] == 'mailgun' + + await http_client.post( + '/api/webhook/{}'.format(webhook_id_without_api_key), + json={ + 'hello': 'mailgun', + } + ) + + assert len(mailgun_events) == event_count + 1 + assert mailgun_events[-1].data['webhook_id'] == webhook_id_without_api_key + assert mailgun_events[-1].data['hello'] == 'mailgun' + + +async def test_mailgun_webhook_event_without_an_api_key( + http_client, + webhook_id_without_api_key, + mailgun_events +): + """Test that webhook triggers an event if there is no api key.""" + timestamp = '1529006854' + token = 'a8ce0edb2dd8301dee6c2405235584e45aa91d1e9f979f3de0' + + event_count = len(mailgun_events) + + await http_client.post( + '/api/webhook/{}'.format(webhook_id_without_api_key), + json={ + 'hello': 'mailgun', + 'signature': { + 'signature': hmac.new( + key=bytes(API_KEY, 'utf-8'), + msg=bytes('{}{}'.format(timestamp, token), 'utf-8'), + digestmod=hashlib.sha256 + ).hexdigest(), + 'timestamp': timestamp, + 'token': token + } + } + ) + + assert len(mailgun_events) == event_count + 1 + assert mailgun_events[-1].data['webhook_id'] == webhook_id_without_api_key + assert mailgun_events[-1].data['hello'] == 'mailgun'