Skip to content

Commit

Permalink
Prevent cloud remote UI when using 127.0.0.1 as trusted network (#22093)
Browse files Browse the repository at this point in the history
* Prevent cloud remote UI when using trusted networks

* Limit to 127.0.0.1 trusted network

* Update error msg

* Disable ipv6 loopback
  • Loading branch information
balloob committed Mar 16, 2019
1 parent 4226503 commit dbdf555
Show file tree
Hide file tree
Showing 4 changed files with 177 additions and 34 deletions.
4 changes: 4 additions & 0 deletions homeassistant/components/cloud/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,7 @@
MODE_PROD = "production"

DISPATCHER_REMOTE_UPDATE = 'cloud_remote_update'


class InvalidTrustedNetworks(Exception):
"""Raised when invalid trusted networks config."""
73 changes: 41 additions & 32 deletions homeassistant/components/cloud/http_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@

from .const import (
DOMAIN, REQUEST_TIMEOUT, PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE,
PREF_GOOGLE_ALLOW_UNLOCK)
PREF_GOOGLE_ALLOW_UNLOCK, InvalidTrustedNetworks)

_LOGGER = logging.getLogger(__name__)

Expand Down Expand Up @@ -58,7 +58,11 @@
})


_CLOUD_ERRORS = {}
_CLOUD_ERRORS = {
InvalidTrustedNetworks:
(500, 'Remote UI not compatible with 127.0.0.1/::1'
' as a trusted network.')
}


async def async_setup(hass):
Expand Down Expand Up @@ -106,7 +110,9 @@ async def async_setup(hass):
auth.PasswordChangeRequired:
(400, 'Password change required.'),
asyncio.TimeoutError:
(502, 'Unable to reach the Home Assistant cloud.')
(502, 'Unable to reach the Home Assistant cloud.'),
aiohttp.ClientError:
(500, 'Error making internal request'),
})


Expand All @@ -120,19 +126,39 @@ async def error_handler(view, request, *args, **kwargs):
return result

except Exception as err: # pylint: disable=broad-except
err_info = _CLOUD_ERRORS.get(err.__class__)
if err_info is None:
_LOGGER.exception(
"Unexpected error processing request for %s", request.path)
err_info = (502, 'Unexpected error: {}'.format(err))
status, msg = err_info
status, msg = _process_cloud_exception(err, request.path)
return view.json_message(
msg, status_code=status,
message_code=err.__class__.__name__.lower())

return error_handler


def _ws_handle_cloud_errors(handler):
"""Websocket decorator to handle auth errors."""
@wraps(handler)
async def error_handler(hass, connection, msg):
"""Handle exceptions that raise from the wrapped handler."""
try:
return await handler(hass, connection, msg)

except Exception as err: # pylint: disable=broad-except
err_status, err_msg = _process_cloud_exception(err, msg['type'])
connection.send_error(msg['id'], err_status, err_msg)

return error_handler


def _process_cloud_exception(exc, where):
"""Process a cloud exception."""
err_info = _CLOUD_ERRORS.get(exc.__class__)
if err_info is None:
_LOGGER.exception(
"Unexpected error processing request for %s", where)
err_info = (502, 'Unexpected error: {}'.format(exc))
return err_info


class GoogleActionsSyncView(HomeAssistantView):
"""Trigger a Google Actions Smart Home Sync."""

Expand Down Expand Up @@ -295,26 +321,6 @@ def with_cloud_auth(hass, connection, msg):
return with_cloud_auth


def _handle_aiohttp_errors(handler):
"""Websocket decorator that handlers aiohttp errors.
Can only wrap async handlers.
"""
@wraps(handler)
async def with_error_handling(hass, connection, msg):
"""Handle aiohttp errors."""
try:
await handler(hass, connection, msg)
except asyncio.TimeoutError:
connection.send_message(websocket_api.error_message(
msg['id'], 'timeout', 'Command timed out.'))
except aiohttp.ClientError:
connection.send_message(websocket_api.error_message(
msg['id'], 'unknown', 'Error making request.'))

return with_error_handling


@_require_cloud_login
@websocket_api.async_response
async def websocket_subscription(hass, connection, msg):
Expand Down Expand Up @@ -363,7 +369,7 @@ async def websocket_update_prefs(hass, connection, msg):

@_require_cloud_login
@websocket_api.async_response
@_handle_aiohttp_errors
@_ws_handle_cloud_errors
async def websocket_hook_create(hass, connection, msg):
"""Handle request for account info."""
cloud = hass.data[DOMAIN]
Expand All @@ -373,6 +379,7 @@ async def websocket_hook_create(hass, connection, msg):

@_require_cloud_login
@websocket_api.async_response
@_ws_handle_cloud_errors
async def websocket_hook_delete(hass, connection, msg):
"""Handle request for account info."""
cloud = hass.data[DOMAIN]
Expand Down Expand Up @@ -417,25 +424,27 @@ def _account_data(cloud):

@_require_cloud_login
@websocket_api.async_response
@_ws_handle_cloud_errors
@websocket_api.websocket_command({
'type': 'cloud/remote/connect'
})
async def websocket_remote_connect(hass, connection, msg):
"""Handle request for connect remote."""
cloud = hass.data[DOMAIN]
await cloud.remote.connect()
await cloud.client.prefs.async_update(remote_enabled=True)
await cloud.remote.connect()
connection.send_result(msg['id'], _account_data(cloud))


@_require_cloud_login
@websocket_api.async_response
@_ws_handle_cloud_errors
@websocket_api.websocket_command({
'type': 'cloud/remote/disconnect'
})
async def websocket_remote_disconnect(hass, connection, msg):
"""Handle request for disconnect remote."""
cloud = hass.data[DOMAIN]
await cloud.remote.disconnect()
await cloud.client.prefs.async_update(remote_enabled=False)
await cloud.remote.disconnect()
connection.send_result(msg['id'], _account_data(cloud))
35 changes: 33 additions & 2 deletions homeassistant/components/cloud/prefs.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
"""Preference management for cloud."""
from ipaddress import ip_address

from .const import (
DOMAIN, PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE, PREF_ENABLE_REMOTE,
PREF_GOOGLE_ALLOW_UNLOCK, PREF_CLOUDHOOKS, PREF_CLOUD_USER)
PREF_GOOGLE_ALLOW_UNLOCK, PREF_CLOUDHOOKS, PREF_CLOUD_USER,
InvalidTrustedNetworks)

STORAGE_KEY = DOMAIN
STORAGE_VERSION = 1
Expand All @@ -13,6 +16,7 @@ class CloudPreferences:

def __init__(self, hass):
"""Initialize cloud prefs."""
self._hass = hass
self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY)
self._prefs = None

Expand Down Expand Up @@ -48,6 +52,9 @@ async def async_update(self, *, google_enabled=_UNDEF,
if value is not _UNDEF:
self._prefs[key] = value

if remote_enabled is True and self._has_local_trusted_network:
raise InvalidTrustedNetworks

await self._store.async_save(self._prefs)

def as_dict(self):
Expand All @@ -57,7 +64,15 @@ def as_dict(self):
@property
def remote_enabled(self):
"""Return if remote is enabled on start."""
return self._prefs.get(PREF_ENABLE_REMOTE, False)
enabled = self._prefs.get(PREF_ENABLE_REMOTE, False)

if not enabled:
return False

if self._has_local_trusted_network:
return False

return True

@property
def alexa_enabled(self):
Expand All @@ -83,3 +98,19 @@ def cloudhooks(self):
def cloud_user(self) -> str:
"""Return ID from Home Assistant Cloud system user."""
return self._prefs.get(PREF_CLOUD_USER)

@property
def _has_local_trusted_network(self) -> bool:
"""Return if we allow localhost to bypass auth."""
local4 = ip_address('127.0.0.1')
local6 = ip_address('::1')

for prv in self._hass.auth.auth_providers:
if prv.type != 'trusted_networks':
continue

for network in prv.trusted_networks:
if local4 in network or local6 in network:
return True

return False
99 changes: 99 additions & 0 deletions tests/components/cloud/test_http_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from hass_nabucasa.auth import Unauthenticated, UnknownError
from hass_nabucasa.const import STATE_CONNECTED

from homeassistant.auth.providers import trusted_networks as tn_auth
from homeassistant.components.cloud.const import (
PREF_ENABLE_GOOGLE, PREF_ENABLE_ALEXA, PREF_GOOGLE_ALLOW_UNLOCK, DOMAIN)

Expand Down Expand Up @@ -589,3 +590,101 @@ async def test_disabling_remote(hass, hass_ws_client, setup_api,
assert not cloud.client.remote_autostart

assert len(mock_disconnect.mock_calls) == 1


async def test_enabling_remote_trusted_networks_local4(
hass, hass_ws_client, setup_api, mock_cloud_login):
"""Test we cannot enable remote UI when trusted networks active."""
hass.auth._providers[('trusted_networks', None)] = \
tn_auth.TrustedNetworksAuthProvider(
hass, None, tn_auth.CONFIG_SCHEMA({
'type': 'trusted_networks',
'trusted_networks': [
'127.0.0.1'
]
})
)

client = await hass_ws_client(hass)

with patch(
'hass_nabucasa.remote.RemoteUI.connect',
side_effect=AssertionError
) as mock_connect:
await client.send_json({
'id': 5,
'type': 'cloud/remote/connect',
})
response = await client.receive_json()

assert not response['success']
assert response['error']['code'] == 500
assert response['error']['message'] == \
'Remote UI not compatible with 127.0.0.1/::1 as a trusted network.'

assert len(mock_connect.mock_calls) == 0


async def test_enabling_remote_trusted_networks_local6(
hass, hass_ws_client, setup_api, mock_cloud_login):
"""Test we cannot enable remote UI when trusted networks active."""
hass.auth._providers[('trusted_networks', None)] = \
tn_auth.TrustedNetworksAuthProvider(
hass, None, tn_auth.CONFIG_SCHEMA({
'type': 'trusted_networks',
'trusted_networks': [
'::1'
]
})
)

client = await hass_ws_client(hass)

with patch(
'hass_nabucasa.remote.RemoteUI.connect',
side_effect=AssertionError
) as mock_connect:
await client.send_json({
'id': 5,
'type': 'cloud/remote/connect',
})
response = await client.receive_json()

assert not response['success']
assert response['error']['code'] == 500
assert response['error']['message'] == \
'Remote UI not compatible with 127.0.0.1/::1 as a trusted network.'

assert len(mock_connect.mock_calls) == 0


async def test_enabling_remote_trusted_networks_other(
hass, hass_ws_client, setup_api, mock_cloud_login):
"""Test we cannot enable remote UI when trusted networks active."""
hass.auth._providers[('trusted_networks', None)] = \
tn_auth.TrustedNetworksAuthProvider(
hass, None, tn_auth.CONFIG_SCHEMA({
'type': 'trusted_networks',
'trusted_networks': [
'192.168.0.0/24'
]
})
)

client = await hass_ws_client(hass)
cloud = hass.data[DOMAIN]

with patch(
'hass_nabucasa.remote.RemoteUI.connect',
return_value=mock_coro()
) as mock_connect:
await client.send_json({
'id': 5,
'type': 'cloud/remote/connect',
})
response = await client.receive_json()

assert response['success']
assert cloud.client.remote_autostart

assert len(mock_connect.mock_calls) == 1

0 comments on commit dbdf555

Please sign in to comment.