diff --git a/homeassistant/config.py b/homeassistant/config.py index 2d90838cb9d..894d0bb5379 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -20,6 +20,7 @@ from homeassistant.const import ( ATTR_ASSUMED_STATE, ATTR_FRIENDLY_NAME, ATTR_HIDDEN, + CONF_ALLOWLIST_EXTERNAL_URLS, CONF_AUTH_MFA_MODULES, CONF_AUTH_PROVIDERS, CONF_CUSTOMIZE, @@ -185,6 +186,7 @@ CORE_CONFIG_SCHEMA = CUSTOMIZE_CONFIG_SCHEMA.extend( vol.Optional(CONF_WHITELIST_EXTERNAL_DIRS): vol.All( cv.ensure_list, [vol.IsDir()] # pylint: disable=no-value-for-parameter ), + vol.Optional(CONF_ALLOWLIST_EXTERNAL_URLS): vol.All(cv.ensure_list, [cv.url]), vol.Optional(CONF_PACKAGES, default={}): PACKAGES_CONFIG_SCHEMA, vol.Optional(CONF_AUTH_PROVIDERS): vol.All( cv.ensure_list, @@ -502,6 +504,14 @@ async def async_process_ha_core_config(hass: HomeAssistant, config: Dict) -> Non if CONF_WHITELIST_EXTERNAL_DIRS in config: hac.whitelist_external_dirs.update(set(config[CONF_WHITELIST_EXTERNAL_DIRS])) + # Init whitelist external URL list – make sure to add / to every URL that doesn't + # already have it so that we can properly test "path ownership" + if CONF_ALLOWLIST_EXTERNAL_URLS in config: + hac.allowlist_external_urls.update( + url if url.endswith("/") else f"{url}/" + for url in config[CONF_ALLOWLIST_EXTERNAL_URLS] + ) + # Customize cust_exact = dict(config[CONF_CUSTOMIZE]) cust_domain = dict(config[CONF_CUSTOMIZE_DOMAIN]) diff --git a/homeassistant/const.py b/homeassistant/const.py index c95c6ec48cc..900df1f9336 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -32,13 +32,14 @@ CONF_ACCESS_TOKEN = "access_token" CONF_ADDRESS = "address" CONF_AFTER = "after" CONF_ALIAS = "alias" +CONF_ALLOWLIST_EXTERNAL_URLS = "allowlist_external_urls" CONF_API_KEY = "api_key" CONF_API_VERSION = "api_version" CONF_ARMING_TIME = "arming_time" CONF_AT = "at" +CONF_AUTHENTICATION = "authentication" CONF_AUTH_MFA_MODULES = "auth_mfa_modules" CONF_AUTH_PROVIDERS = "auth_providers" -CONF_AUTHENTICATION = "authentication" CONF_BASE = "base" CONF_BEFORE = "before" CONF_BELOW = "below" @@ -68,9 +69,9 @@ CONF_CUSTOMIZE_GLOB = "customize_glob" CONF_DELAY = "delay" CONF_DELAY_TIME = "delay_time" CONF_DEVICE = "device" +CONF_DEVICES = "devices" CONF_DEVICE_CLASS = "device_class" CONF_DEVICE_ID = "device_id" -CONF_DEVICES = "devices" CONF_DISARM_AFTER_TRIGGER = "disarm_after_trigger" CONF_DISCOVERY = "discovery" CONF_DISKS = "disks" @@ -90,8 +91,8 @@ CONF_EVENT_DATA = "event_data" CONF_EVENT_DATA_TEMPLATE = "event_data_template" CONF_EXCLUDE = "exclude" CONF_EXTERNAL_URL = "external_url" -CONF_FILE_PATH = "file_path" CONF_FILENAME = "filename" +CONF_FILE_PATH = "file_path" CONF_FOR = "for" CONF_FORCE_UPDATE = "force_update" CONF_FRIENDLY_NAME = "friendly_name" @@ -138,15 +139,15 @@ CONF_RADIUS = "radius" CONF_RECIPIENT = "recipient" CONF_REGION = "region" CONF_RESOURCE = "resource" -CONF_RESOURCE_TEMPLATE = "resource_template" CONF_RESOURCES = "resources" +CONF_RESOURCE_TEMPLATE = "resource_template" CONF_RGB = "rgb" CONF_ROOM = "room" CONF_SCAN_INTERVAL = "scan_interval" CONF_SCENE = "scene" CONF_SENDER = "sender" -CONF_SENSOR_TYPE = "sensor_type" CONF_SENSORS = "sensors" +CONF_SENSOR_TYPE = "sensor_type" CONF_SERVICE = "service" CONF_SERVICE_DATA = "data" CONF_SERVICE_TEMPLATE = "service_template" @@ -159,8 +160,8 @@ CONF_STATE_TEMPLATE = "state_template" CONF_STRUCTURE = "structure" CONF_SWITCHES = "switches" CONF_TEMPERATURE_UNIT = "temperature_unit" -CONF_TIME_ZONE = "time_zone" CONF_TIMEOUT = "timeout" +CONF_TIME_ZONE = "time_zone" CONF_TOKEN = "token" CONF_TRIGGER_TIME = "trigger_time" CONF_TTL = "ttl" @@ -174,9 +175,9 @@ CONF_VERIFY_SSL = "verify_ssl" CONF_WAIT_TEMPLATE = "wait_template" CONF_WEBHOOK_ID = "webhook_id" CONF_WEEKDAY = "weekday" -CONF_WHITE_VALUE = "white_value" CONF_WHITELIST = "whitelist" CONF_WHITELIST_EXTERNAL_DIRS = "whitelist_external_dirs" +CONF_WHITE_VALUE = "white_value" CONF_XY = "xy" CONF_ZONE = "zone" diff --git a/homeassistant/core.py b/homeassistant/core.py index f8f4e7c0d02..da4c5b56146 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -1332,6 +1332,9 @@ class Config: # List of allowed external dirs to access self.whitelist_external_dirs: Set[str] = set() + # List of allowed external URLs that integrations may use + self.allowlist_external_urls: Set[str] = set() + # If Home Assistant is running in safe mode self.safe_mode: bool = False @@ -1353,6 +1356,16 @@ class Config: raise HomeAssistantError("config_dir is not set") return os.path.join(self.config_dir, *path) + def is_allowed_external_url(self, url: str) -> bool: + """Check if an external URL is allowed.""" + parsed_url = f"{str(yarl.URL(url))}/" + + return any( + allowed + for allowed in self.allowlist_external_urls + if parsed_url.startswith(allowed) + ) + def is_allowed_path(self, path: str) -> bool: """Check if the path is valid for access from outside.""" assert path is not None @@ -1395,6 +1408,7 @@ class Config: "components": self.components, "config_dir": self.config_dir, "whitelist_external_dirs": self.whitelist_external_dirs, + "allowlist_external_urls": self.allowlist_external_urls, "version": __version__, "config_source": self.config_source, "safe_mode": self.safe_mode, diff --git a/tests/components/api/test_init.py b/tests/components/api/test_init.py index 1c93158ec03..24a1532dead 100644 --- a/tests/components/api/test_init.py +++ b/tests/components/api/test_init.py @@ -212,6 +212,8 @@ async def test_api_get_config(hass, mock_api_client): result["components"] = set(result["components"]) if "whitelist_external_dirs" in result: result["whitelist_external_dirs"] = set(result["whitelist_external_dirs"]) + if "allowlist_external_urls" in result: + result["allowlist_external_urls"] = set(result["allowlist_external_urls"]) assert hass.config.as_dict() == result diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index 3754767dd9e..6f7b288f292 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -234,6 +234,10 @@ async def test_get_config(hass, websocket_client): msg["result"]["whitelist_external_dirs"] = set( msg["result"]["whitelist_external_dirs"] ) + if "allowlist_external_urls" in msg["result"]: + msg["result"]["allowlist_external_urls"] = set( + msg["result"]["allowlist_external_urls"] + ) assert msg["result"] == hass.config.as_dict() diff --git a/tests/test_core.py b/tests/test_core.py index c4079328f1f..32634313a48 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -915,6 +915,7 @@ class TestConfig(unittest.TestCase): "components": set(), "config_dir": "/test/ha-config", "whitelist_external_dirs": set(), + "allowlist_external_urls": set(), "version": __version__, "config_source": "default", "safe_mode": False, @@ -955,6 +956,33 @@ class TestConfig(unittest.TestCase): with pytest.raises(AssertionError): self.config.is_allowed_path(None) + def test_is_allowed_external_url(self): + """Test is_allowed_external_url method.""" + self.config.allowlist_external_urls = [ + "http://x.com/", + "https://y.com/bla/", + "https://z.com/images/1.jpg/", + ] + + valid = [ + "http://x.com/1.jpg", + "http://x.com", + "https://y.com/bla/", + "https://y.com/bla/2.png", + "https://z.com/images/1.jpg", + ] + for url in valid: + assert self.config.is_allowed_external_url(url) + + invalid = [ + "https://a.co", + "https://y.com/bla_wrong", + "https://y.com/bla/../image.jpg", + "https://z.com/images", + ] + for url in invalid: + assert not self.config.is_allowed_external_url(url) + async def test_event_on_update(hass): """Test that event is fired on update."""