Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 40 additions & 6 deletions homeassistant/components/mailgun/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@
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

Expand All @@ -12,7 +16,7 @@
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'
Expand All @@ -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):
Expand All @@ -59,8 +94,7 @@ async def async_unload_entry(hass, entry):
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/'
}
)
2 changes: 1 addition & 1 deletion homeassistant/components/mailgun/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
}
}
230 changes: 211 additions & 19 deletions tests/components/mailgun/test_init.py
Original file line number Diff line number Diff line change
@@ -1,39 +1,231 @@
"""Test the init file of Mailgun."""
from unittest.mock import patch
import hashlib
import hmac
from unittest.mock import Mock

from homeassistant import data_entry_flow
from homeassistant.components import mailgun
import pytest

from homeassistant import data_entry_flow
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'


@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

return result['result'].data['webhook_id']

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 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
webhook_id = result['result'].data['webhook_id']

mailgun_events = []
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


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) == 1
assert mailgun_events[0].data['webhook_id'] == webhook_id
assert mailgun_events[0].data['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'