Prevent cloud remote UI when using 127.0.0.1 as trusted network (#22093)
* Prevent cloud remote UI when using trusted networks * Limit to 127.0.0.1 trusted network * Update error msg * Disable ipv6 loopback
This commit is contained in:
parent
42265036ff
commit
dbdf5558e6
4 changed files with 177 additions and 34 deletions
|
@ -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."""
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Add table
Reference in a new issue