diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py index 2816e3f6dc9b..1286832c0c7a 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -27,3 +27,7 @@ MODE_DEV = "development" MODE_PROD = "production" DISPATCHER_REMOTE_UPDATE = 'cloud_remote_update' + + +class InvalidTrustedNetworks(Exception): + """Raised when invalid trusted networks config.""" diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index 61b3b8576ecc..212bdfb4bf8a 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -18,7 +18,7 @@ from homeassistant.components.google_assistant import smart_home as google_sh 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__) @@ -58,7 +58,11 @@ SCHEMA_WS_HOOK_DELETE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ }) -_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): @@ -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'), }) @@ -120,12 +126,7 @@ def _handle_cloud_errors(handler): 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()) @@ -133,6 +134,31 @@ def _handle_cloud_errors(handler): 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.""" @@ -295,26 +321,6 @@ def _require_cloud_login(handler): 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): @@ -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] @@ -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] @@ -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)) diff --git a/homeassistant/components/cloud/prefs.py b/homeassistant/components/cloud/prefs.py index 16ff8f0c2133..b0244f6b1fb1 100644 --- a/homeassistant/components/cloud/prefs.py +++ b/homeassistant/components/cloud/prefs.py @@ -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 @@ -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 @@ -48,6 +52,9 @@ class CloudPreferences: 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): @@ -57,7 +64,15 @@ class CloudPreferences: @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): @@ -83,3 +98,19 @@ class CloudPreferences: 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 diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index 3ab4b1030fa0..6c50a158cad3 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -7,6 +7,7 @@ from jose import jwt 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) @@ -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