diff --git a/API.md b/API.md index de8aca967..e0bb0159b 100644 --- a/API.md +++ b/API.md @@ -478,6 +478,9 @@ Get all available addons. "changelog": "bool", "hassio_api": "bool", "homeassistant_api": "bool", + "full_access": "bool", + "protected": "bool", + "rating": "1-5", "stdin": "bool", "webui": "null|http(s)://[HOST]:port/xy/zx", "gpio": "bool", @@ -507,6 +510,7 @@ Get all available addons. "CONTAINER": "port|[ip, port]" }, "options": {}, + "protected": "bool", "audio_output": "null|0,0", "audio_input": "null|0,0" } diff --git a/hassio/addons/addon.py b/hassio/addons/addon.py index 6dac66d0d..103d1bed4 100644 --- a/hassio/addons/addon.py +++ b/hassio/addons/addon.py @@ -25,8 +25,9 @@ from ..const import ( ATTR_HASSIO_API, ATTR_AUDIO, ATTR_AUDIO_OUTPUT, ATTR_AUDIO_INPUT, ATTR_GPIO, ATTR_HOMEASSISTANT_API, ATTR_STDIN, ATTR_LEGACY, ATTR_HOST_IPC, ATTR_HOST_DBUS, ATTR_AUTO_UART, ATTR_DISCOVERY, ATTR_SERVICES, - ATTR_APPARMOR, ATTR_DEVICETREE, ATTR_DOCKER_API, SECURITY_PROFILE, - SECURITY_DISABLE, SECURITY_DEFAULT) + ATTR_APPARMOR, ATTR_DEVICETREE, ATTR_DOCKER_API, ATTR_FULL_ACCESS, + ATTR_PROTECTED, + SECURITY_PROFILE, SECURITY_DISABLE, SECURITY_DEFAULT) from ..coresys import CoreSysAttributes from ..docker.addon import DockerAddon from ..utils.json import write_json_file, read_json_file @@ -201,6 +202,18 @@ class Addon(CoreSysAttributes): return self._data.cache[self._id][ATTR_VERSION] return self.version_installed + @property + def protected(self): + """Return if addon is in protected mode.""" + if self.is_installed: + return self._data.user[self._id][ATTR_PROTECTED] + return True + + @protected.setter + def protected(self, value): + """Set addon in protected mode.""" + self._data.user[self._id][ATTR_PROTECTED] = value + @property def startup(self): """Return startup type of addon.""" @@ -336,7 +349,7 @@ class Addon(CoreSysAttributes): return self._mesh.get(ATTR_LEGACY) @property - def with_docker_api(self): + def access_docker_api(self): """Return if the add-on need read-only docker API access.""" return self._mesh.get(ATTR_DOCKER_API) @@ -360,6 +373,11 @@ class Addon(CoreSysAttributes): """Return True if the add-on access to gpio interface.""" return self._mesh[ATTR_GPIO] + @property + def with_full_access(self): + """Return True if the add-on want full access to hardware.""" + return self._mesh[ATTR_FULL_ACCESS] + @property def with_devicetree(self): """Return True if the add-on read access to devicetree.""" diff --git a/hassio/addons/utils.py b/hassio/addons/utils.py index c876312d0..27c393f09 100644 --- a/hassio/addons/utils.py +++ b/hassio/addons/utils.py @@ -4,11 +4,49 @@ import hashlib import logging import re +from ..const import ( + SECURITY_DISABLE, SECURITY_PROFILE, PRIVILEGED_NET_ADMIN, + PRIVILEGED_SYS_ADMIN, PRIVILEGED_SYS_RAWIO) + RE_SHA1 = re.compile(r"[a-f0-9]{8}") _LOGGER = logging.getLogger(__name__) +def rating_security(addon): + """Return 1-5 for security rating. + + 1 = not secure + 5 = high secure + """ + rating = 4 + + # AppArmor + if addon.apparmor == SECURITY_DISABLE: + rating += -1 + elif addon.apparmor == SECURITY_PROFILE: + rating += 1 + + # API Access + if addon.access_hassio_api or addon.access_homeassistant_api: + rating += -1 + + # Privileged options + if addon.privileged in (PRIVILEGED_NET_ADMIN, PRIVILEGED_SYS_ADMIN, + PRIVILEGED_SYS_RAWIO): + rating += -1 + + # Full Access + if addon.with_full_access: + rating += -2 + + # Docker Access + if addon.access_docker_api: + rating = 1 + + return max(min(5, rating), 1) + + def get_hash_from_repository(name): """Generate a hash from repository.""" key = name.lower().encode() diff --git a/hassio/addons/validate.py b/hassio/addons/validate.py index 008ad3fb1..d5881bead 100644 --- a/hassio/addons/validate.py +++ b/hassio/addons/validate.py @@ -18,7 +18,11 @@ from ..const import ( ATTR_AUDIO_OUTPUT, ATTR_HASSIO_API, ATTR_BUILD_FROM, ATTR_SQUASH, ATTR_ARGS, ATTR_GPIO, ATTR_HOMEASSISTANT_API, ATTR_STDIN, ATTR_LEGACY, ATTR_HOST_DBUS, ATTR_AUTO_UART, ATTR_SERVICES, ATTR_DISCOVERY, - ATTR_APPARMOR, ATTR_DEVICETREE, ATTR_DOCKER_API) + ATTR_APPARMOR, ATTR_DEVICETREE, ATTR_DOCKER_API, ATTR_PROTECTED, + ATTR_FULL_ACCESS, + PRIVILEGED_NET_ADMIN, PRIVILEGED_SYS_ADMIN, PRIVILEGED_SYS_RAWIO, + PRIVILEGED_IPC_LOCK, PRIVILEGED_SYS_TIME, PRIVILEGED_SYS_NICE, + PRIVILEGED_SYS_RESOURCE) from ..validate import NETWORK_PORT, DOCKER_PORTS, ALSA_DEVICE _LOGGER = logging.getLogger(__name__) @@ -58,13 +62,13 @@ STARTUP_ALL = [ ] PRIVILEGED_ALL = [ - "NET_ADMIN", - "SYS_ADMIN", - "SYS_RAWIO", - "IPC_LOCK", - "SYS_TIME", - "SYS_NICE", - "SYS_RESOURCE" + PRIVILEGED_NET_ADMIN, + PRIVILEGED_SYS_ADMIN, + PRIVILEGED_SYS_RAWIO, + PRIVILEGED_IPC_LOCK, + PRIVILEGED_SYS_TIME, + PRIVILEGED_SYS_NICE, + PRIVILEGED_SYS_RESOURCE, ] BASE_IMAGE = { @@ -110,6 +114,7 @@ SCHEMA_ADDON_CONFIG = vol.Schema({ vol.Optional(ATTR_ENVIRONMENT): {vol.Match(r"\w*"): vol.Coerce(str)}, vol.Optional(ATTR_PRIVILEGED): [vol.In(PRIVILEGED_ALL)], vol.Optional(ATTR_APPARMOR, default=True): vol.Boolean(), + vol.Optional(ATTR_FULL_ACCESS, default=False): vol.Boolean(), vol.Optional(ATTR_AUDIO, default=False): vol.Boolean(), vol.Optional(ATTR_GPIO, default=False): vol.Boolean(), vol.Optional(ATTR_DEVICETREE, default=False): vol.Boolean(), @@ -170,6 +175,7 @@ SCHEMA_ADDON_USER = vol.Schema({ vol.Optional(ATTR_NETWORK): DOCKER_PORTS, vol.Optional(ATTR_AUDIO_OUTPUT): ALSA_DEVICE, vol.Optional(ATTR_AUDIO_INPUT): ALSA_DEVICE, + vol.Optional(ATTR_PROTECTED, default=True): vol.Boolean(), }, extra=vol.REMOVE_EXTRA) diff --git a/hassio/api/addons.py b/hassio/api/addons.py index 12fcc732b..927271ddb 100644 --- a/hassio/api/addons.py +++ b/hassio/api/addons.py @@ -6,6 +6,7 @@ import voluptuous as vol from voluptuous.humanize import humanize_error from .utils import api_process, api_process_raw, api_validate +from ..addons.utils import rating_security from ..const import ( ATTR_VERSION, ATTR_LAST_VERSION, ATTR_STATE, ATTR_BOOT, ATTR_OPTIONS, ATTR_URL, ATTR_DESCRIPTON, ATTR_DETACHED, ATTR_NAME, ATTR_REPOSITORY, @@ -18,9 +19,11 @@ from ..const import ( ATTR_CPU_PERCENT, ATTR_MEMORY_LIMIT, ATTR_MEMORY_USAGE, ATTR_NETWORK_TX, ATTR_NETWORK_RX, ATTR_BLK_READ, ATTR_BLK_WRITE, ATTR_ICON, ATTR_SERVICES, ATTR_DISCOVERY, ATTR_APPARMOR, ATTR_DEVICETREE, ATTR_DOCKER_API, - CONTENT_TYPE_PNG, CONTENT_TYPE_BINARY, CONTENT_TYPE_TEXT) + ATTR_FULL_ACCESS, ATTR_PROTECTED, ATTR_RATING, + CONTENT_TYPE_PNG, CONTENT_TYPE_BINARY, CONTENT_TYPE_TEXT, REQUEST_FROM) from ..coresys import CoreSysAttributes from ..validate import DOCKER_PORTS, ALSA_DEVICE +from ..exceptions import APINotSupportedError _LOGGER = logging.getLogger(__name__) @@ -35,6 +38,7 @@ SCHEMA_OPTIONS = vol.Schema({ vol.Optional(ATTR_AUTO_UPDATE): vol.Boolean(), vol.Optional(ATTR_AUDIO_OUTPUT): ALSA_DEVICE, vol.Optional(ATTR_AUDIO_INPUT): ALSA_DEVICE, + vol.Optional(ATTR_PROTECTED): vol.Boolean(), }) @@ -116,6 +120,8 @@ class APIAddons(CoreSysAttributes): ATTR_REPOSITORY: addon.repository, ATTR_LAST_VERSION: addon.last_version, ATTR_STATE: await addon.state(), + ATTR_PROTECTED: addon.protected, + ATTR_RATING: rating_security(addon), ATTR_BOOT: addon.boot, ATTR_OPTIONS: addon.options, ATTR_URL: addon.url, @@ -126,6 +132,7 @@ class APIAddons(CoreSysAttributes): ATTR_HOST_IPC: addon.host_ipc, ATTR_HOST_DBUS: addon.host_dbus, ATTR_PRIVILEGED: addon.privileged, + ATTR_FULL_ACCESS: addon.with_full_access, ATTR_APPARMOR: addon.apparmor, ATTR_DEVICES: self._pretty_devices(addon), ATTR_ICON: addon.with_icon, @@ -137,7 +144,7 @@ class APIAddons(CoreSysAttributes): ATTR_HOMEASSISTANT_API: addon.access_homeassistant_api, ATTR_GPIO: addon.with_gpio, ATTR_DEVICETREE: addon.with_devicetree, - ATTR_DOCKER_API: addon.with_docker_api, + ATTR_DOCKER_API: addon.access_docker_api, ATTR_AUDIO: addon.with_audio, ATTR_AUDIO_INPUT: addon.audio_input, ATTR_AUDIO_OUTPUT: addon.audio_output, @@ -150,6 +157,11 @@ class APIAddons(CoreSysAttributes): """Store user options for addon.""" addon = self._extract_addon(request) + # Have Access + if addon.slug == request[REQUEST_FROM]: + _LOGGER.error("Add-on can't self modify his options!") + raise APINotSupportedError() + addon_schema = SCHEMA_OPTIONS.extend({ vol.Optional(ATTR_OPTIONS): vol.Any(None, addon.schema), }) @@ -168,6 +180,9 @@ class APIAddons(CoreSysAttributes): addon.audio_input = body[ATTR_AUDIO_INPUT] if ATTR_AUDIO_OUTPUT in body: addon.audio_output = body[ATTR_AUDIO_OUTPUT] + if ATTR_PROTECTED in body: + _LOGGER.warning("Protected flag changing for %s!", addon.slug) + addon.protected = body[ATTR_PROTECTED] addon.save_data() return True diff --git a/hassio/api/utils.py b/hassio/api/utils.py index b3ce8a4db..f4f5f9e32 100644 --- a/hassio/api/utils.py +++ b/hassio/api/utils.py @@ -30,10 +30,10 @@ def api_process(method): """Return api information.""" try: answer = await method(api, *args, **kwargs) - except RuntimeError as err: - return api_return_error(message=str(err)) except HassioError: return api_return_error() + except RuntimeError as err: + return api_return_error(message=str(err)) if isinstance(answer, dict): return api_return_ok(data=answer) diff --git a/hassio/const.py b/hassio/const.py index 7efdb85f3..1a28061f9 100644 --- a/hassio/const.py +++ b/hassio/const.py @@ -179,6 +179,9 @@ ATTR_VERSION_CLI = 'version_cli' ATTR_VERSION_CLI_LATEST = 'version_cli_latest' ATTR_REFRESH_TOKEN = 'refresh_token' ATTR_DOCKER_API = 'docker_api' +ATTR_FULL_ACCESS = 'full_access' +ATTR_PROTECTED = 'protected' +ATTR_RATING = 'rating' SERVICE_MQTT = 'mqtt' @@ -227,6 +230,14 @@ SECURITY_PROFILE = 'profile' SECURITY_DEFAULT = 'default' SECURITY_DISABLE = 'disable' +PRIVILEGED_NET_ADMIN = 'NET_ADMIN' +PRIVILEGED_SYS_ADMIN = 'SYS_ADMIN' +PRIVILEGED_SYS_RAWIO = 'SYS_RAWIO' +PRIVILEGED_IPC_LOCK = 'IPC_LOCK' +PRIVILEGED_SYS_TIME = 'SYS_TIME' +PRIVILEGED_SYS_NICE = 'SYS_NICE' +PRIVILEGED_SYS_RESOURCE = 'SYS_RESOURCE' + FEATURES_SHUTDOWN = 'shutdown' FEATURES_REBOOT = 'reboot' FEATURES_HASSOS = 'hassos' diff --git a/hassio/docker/addon.py b/hassio/docker/addon.py index 260a4e9ca..444c4d24a 100644 --- a/hassio/docker/addon.py +++ b/hassio/docker/addon.py @@ -67,6 +67,11 @@ class DockerAddon(DockerInterface): return 'host' return None + @property + def full_access(self): + """Return True if full access is enabled.""" + return not self.addon.protected and self.addon.with_full_access + @property def hostname(self): """Return slug/id of addon.""" @@ -223,7 +228,7 @@ class DockerAddon(DockerInterface): }) # Docker API support - if self.addon.with_docker_api: + if not self.addon.protected and self.addon.access_docker_api: volumes.update({ "/var/run/docker.sock": { 'bind': "/var/run/docker.sock", 'mode': 'ro' @@ -254,6 +259,11 @@ class DockerAddon(DockerInterface): if self._is_running(): return True + # Security check + if not self.addon.protected: + _LOGGER.warning( + "%s run with disabled proteced mode!", self.addon.name) + # cleanup self._stop() @@ -263,6 +273,7 @@ class DockerAddon(DockerInterface): hostname=self.hostname, detach=True, init=True, + privileged=self.full_access, ipc_mode=self.ipc, stdin_open=self.addon.with_stdin, network_mode=self.network_mode, diff --git a/hassio/exceptions.py b/hassio/exceptions.py index 9cd18e146..89b2207cb 100644 --- a/hassio/exceptions.py +++ b/hassio/exceptions.py @@ -76,6 +76,19 @@ class HostServiceError(HostError): class HostAppArmorError(HostError): """Host apparmor functions fails.""" + pass + + +# API + +class APIError(HassioError): + """API errors.""" + pass + + +class APINotSupportedError(HassioNotSupportedError): + """API not supported error.""" + pass # utils/gdbus