Root cause
Authentication & access control introduced
feat: add managed YAML config editing tool (ha_config_set_yaml) (#827)
How AI contributed: Direct introductionPublished May 14, 2026 in
homeassistant-ai/ha-mcpAll 4 findings in this repository
Summary When ENABLE_YAML_CONFIG_EDITING=true, every ha_config_set_yaml call backs up the pre-edit file to <config>/www/yaml_backups/, which Home Assistant serves at /local/ with no authentication. Anyone who can reach the HA web interface can download the most recent pre-edit configuration.yaml (or other YAML file) — typically containing plaintext MQTT passwords, REST credentials, webhook IDs, geofence coordinates, and shell_command definitions — with zero credentials. Details The backup feature is good — do_backup defaults to True and protects users from a bad edit. The issue is the location: · custom_components/ha_mcp_tools/__init__.py:596 — backup_dir = config_dir / "www" / "yaml_backups" · custom_components/ha_mcp_tools/__init__.py:602 — backup_file = backup_dir / f"{safe_name}.{timestamp}.bak" · custom_components/ha_mcp_tools/__init__.py:606-607,692-693 — backup path returned to caller and logged at INFO <config>/www/ is /local/ and HA serves it unauthenticated by design (intended for static dashboard assets). An attacker discovers the path three ways: (1) it's returned to the MCP client in result["backup_path"]; (2) it's logged at INFO and recoverable via ha_get_logs; (3) the timestamp format is %Y%m%d_%H%M%S — 86,400 candidates per day, enumerable. Backups accumulate (no rotation), so a long-running install holds a chronological history. Preconditions: ENABLE_YAML_CONFIG_EDITING=true (off by default), at least one YAML edit made, and the attacker can reach HA's port 8123 (LAN, or internet via Nabu Casa / reverse proxy). PoC A pytest E2E test against a fresh Docker HA container with the custom component installed (using the project's existing ha_container_with_fresh_config fixture): The control proves the result is meaningful: the same instance returns 401 for an authenticated endpoint; only /local/ is unauthenticated by HA design. Impact CWE-552 (Files or Directories Accessible to External Parties). Affects users with ENABLE_YAML_CONFIG_EDITING=true who have made at least one YAML edit. Anyone who can reach HA port 8123 reads the most recent pre-edit config without credentials. CVSS 6.5 medium (AV:A/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N); 7.5 high if HA is internet-exposed. Resolution Fixed in 7.4.1.dev456 (PR #1180). The fix relocates new backups to <config>/.ha_mcp_tools_backups/ (config root, not served by /local/) and adds a one-time migration on integration setup that moves any pre-existing exposed backups, surfaces a persistent notification telling the user to rotate exposed secrets, and removes the legacy directory if empty. The fix ships in the next biweekly stable release and is available immediately on the dev channel. Updating to a release containing the fix is sufficient — no manual action required for users who upgrade. Manual cleanup (for users who cannot upgrade yet) If you used ha_config_set_yaml on a vulnerable version and cannot wait for the next stable release, manually remove the exposed backups: Then rotate any secrets that may have been in the YAML files those backups captured (MQTT/REST credentials, webhook IDs, shell_command definitions, geofence coordinates). The directory is reachable at http(s)://<ha-host>:8123/local/yaml_backups/ until removed. After the next addon/package upgrade containing the fix, the integration will run this cleanup automatically and surface a persistent notification with the same rotation guidance.
When YAML config editing is enabled, AI-written code creates unauthenticated backups under /local/, exposing configuration.yaml secrets to anyone with web access.
Root cause
feat: add managed YAML config editing tool (ha_config_set_yaml) (#827)
Fix
fix(security): write YAML backups outside www/ (GHSA-g39v-cvjh-8fpf) (#1180)
AI-assisted fix: Claude Code · kingpanther13
Case facts
diff --git a/custom_components/ha_mcp_tools/__init__.py b/custom_components/ha_mcp_tools/__init__.pyindex 2e9093e9..edb543ee 100644--- a/custom_components/ha_mcp_tools/__init__.py+++ b/custom_components/ha_mcp_tools/__init__.py@@ -486,7 +500,213 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: "error": str(err), } + async def handle_edit_yaml_config(call: ServiceCall) -> ServiceResponse:+ """Handle the edit_yaml_config service call."""+ rel_path = call.data["file"]+ action = call.data["action"]+ yaml_path = call.data["yaml_path"]+ content = call.data.get("content")+ do_backup = call.data.get("backup", True)++ # Validate file path — only configuration.yaml and packages/*.yaml+ normalized = os.path.normpath(rel_path) # noqa: ASYNC240+ if normalized.startswith("..") or normalized.startswith("/"):+ return {+ "success": False,+ "error": "Path traversal is not allowed.",+ }++ is_config_yaml = normalized in ALLOWED_YAML_CONFIG_FILES+ is_package = fnmatch.fnmatch(normalized, "packages/*.yaml") or fnmatch.fnmatch(+ normalized, "packages/**/*.yaml"+ )+ if not is_config_yaml and not is_package:+ return {+ "success": False,+ "error": (+ f"File '{rel_path}' is not allowed. "+ f"Only {', '.join(ALLOWED_YAML_CONFIG_FILES)} and packages/*.yaml are supported."+ ),+ }++ # Validate yaml_path against whitelist+ if yaml_path in BLOCKED_YAML_KEYS:+ return {+ "success": False,+ "error": (+ f"Key '{yaml_path}' is blocked for safety. "+ f"Blocked keys: {', '.join(sorted(BLOCKED_YAML_KEYS))}"+ ),+ }+ if yaml_path not in ALLOWED_YAML_KEYS:+ return {+ "success": False,+ "error": (+ f"Key '{yaml_path}' is not in the allowed list. "+ f"Allowed keys: {', '.join(sorted(ALLOWED_YAML_KEYS))}"+ ),+ }++ # Validate content is valid YAML for add/replace+ parsed_content: Any = None+ if action in ("add", "replace"):+ if not content:+ return {+ "success": False,+ "error": f"'content' is required for action '{action}'.",+ }+ try:+ parsed_content = yaml.safe_load(content)+ except yaml.YAMLError as err:+ return {+ "success": False,+ "error": f"Invalid YAML content: {err}",+ }++ target_file = config_dir / normalized+ backup_path_str: str | None = None++ try:+ # Read existing file content (or start with empty dict)+ if target_file.exists():+ raw_content = await hass.async_add_executor_job(target_file.read_text)+ try:+ data = yaml.safe_load(raw_content) or {}+ except yaml.YAMLError as err:+ return {+ "success": False,+ "error": (+ f"Cannot parse existing file '{rel_path}': {err}. "+ "The file may contain !include or !secret directives "+ "that require Home Assistant's YAML loader. "+ "This tool only supports standard YAML."+ ),+ }+ if not isinstance(data, dict):+ return {+ "success": False,+ "error": f"File '{rel_path}' root is not a YAML mapping.",+ }+ else:+ if action == "remove":+ return {+ "success": False,+ "error": f"File does not exist: {rel_path}",+ }+ data = {}+ raw_content = ""++ # Create backup before editing+ if do_backup and raw_content:+ backup_dir = config_dir / "www" / "yaml_backups"+ await hass.async_add_executor_job(+ backup_dir.mkdir, parents=True, exist_ok=True+ )+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")+ safe_name = normalized.replace(os.sep, "_")+ backup_file = backup_dir / f"{safe_name}.{timestamp}.bak"+ await hass.async_add_executor_job(+ shutil.copy2, str(target_file), str(backup_file)+ )+ backup_path_str = str(backup_file.relative_to(config_dir))+ _LOGGER.info("Backup created: %s", backup_path_str)++ # Perform the action+ if action == "add":+ if yaml_path in data:+ existing = data[yaml_path]+ # Merge: list extends list, dict merges dict, else replace+ if isinstance(existing, list) and isinstance(parsed_content, list):+ data[yaml_path] = existing + parsed_content+ elif isinstance(existing, dict) and isinstance(+ parsed_content, dict+ ):+ existing.update(parsed_content)+ else:+ data[yaml_path] = parsed_content+ else:+ data[yaml_path] = parsed_content+ elif action == "replace":+ data[yaml_path] = parsed_content+ elif action == "remove":+ if yaml_path not in data:+ return {+ "success": False,+ "error": f"Key '{yaml_path}' not found in '{rel_path}'.",+ }+ del data[yaml_path]++ # Serialize back to YAML+ new_content = yaml.dump(+ data,+ default_flow_style=False,+ allow_unicode=True,+ sort_keys=False,+ )++ # Validate the result parses cleanly+ try:+ yaml.safe_load(new_content)+ except yaml.YAMLError as err:+ return {+ "success": False,+ "error": f"Generated YAML failed validation: {err}",+ }++ # Create parent directories if needed (for new package files)+ if not target_file.parent.exists():+ await hass.async_add_executor_job(+ target_file.parent.mkdir, parents=True, exist_ok=True+ )++ # Write the file+ await hass.async_add_executor_job(target_file.write_text, new_content)++ stat = target_file.stat()+ modified_dt = datetime.fromtimestamp(stat.st_mtime)++ _LOGGER.info(+ "YAML config edited: %s (action=%s, key=%s)",+ rel_path,+ action,+ yaml_path,+ )++ result: dict[str, Any] = {+ "success": True,+ "file": rel_path,+ "action": action,+ "yaml_path": yaml_path,+ "size": stat.st_size,+ "modified": modified_dt.isoformat(),+ }+ if backup_path_str:+ result["backup_path"] = backup_path_str++ return result++ except PermissionError:+ _LOGGER.error("Permission denied editing: %s", rel_path)+ return {+ "success": False,+ "error": f"Permission denied: {rel_path}",+ }+ except OSError as err:+ _LOGGER.error("Error editing YAML config %s: %s", rel_path, err)+ return {+ "success": False,+ "error": str(err),+ }+ # Register all services with response support+ hass.services.async_register(+ DOMAIN,+ SERVICE_EDIT_YAML_CONFIG,+ handle_edit_yaml_config,+ schema=SERVICE_EDIT_YAML_CONFIG_SCHEMA,+ supports_response=SupportsResponse.ONLY,+ )+ hass.services.async_register( DOMAIN, SERVICE_LIST_FILES,diff --git a/custom_components/ha_mcp_tools/__init__.py b/custom_components/ha_mcp_tools/__init__.pyindex 5df1ac0b..8d89a8e6 100644--- a/custom_components/ha_mcp_tools/__init__.py+++ b/custom_components/ha_mcp_tools/__init__.py@@ -8,0 +9 @@ from __future__ import annotations+import errno@@ -12,0 +14 @@ import re+import shutil@@ -18,0 +21 @@ import voluptuous as vol+from homeassistant.components import persistent_notification@@ -404 +410 @@ def _build_edit_yaml_config_handler(hass):- backup_dir = config_dir / "www" / "yaml_backups"+ backup_dir = config_dir / ".ha_mcp_tools_backups"@@ -679,0 +686,69 @@ def _parse_and_validate_yaml_path(+def _migrate_legacy_backup_dir(config_dir: Path) -> tuple[int, int]:+ """Move pre-fix backups out of www/ (GHSA-g39v-cvjh-8fpf).++ Pre-fix versions wrote backups under www/yaml_backups/, which HA serves+ unauthenticated at /local/. Move .bak files into the new+ .ha_mcp_tools_backups/ directory and remove the old directory if empty.+ Returns (moved, failed) — counts of files successfully relocated and+ files that could not be moved (left in legacy_dir, still exposed).+ """+ legacy_dir = config_dir / "www" / "yaml_backups"+ if not legacy_dir.is_dir():+ return 0, 0++ new_dir = config_dir / ".ha_mcp_tools_backups"+ new_dir.mkdir(parents=True, exist_ok=True)++ moved = 0+ failed = 0+ for src in legacy_dir.iterdir():+ if not src.is_file() or src.is_symlink() or src.suffix != ".bak":+ continue++ dest = new_dir / src.name+ if dest.exists():+ dest = new_dir / f"{src.stem}.legacy{src.suffix}"+ counter = 1+ while dest.exists():+ dest = new_dir / f"{src.stem}.legacy{counter}{src.suffix}"+ counter += 1++ try:+ shutil.move(str(src), str(dest))+ moved += 1+ except OSError as err:+ failed += 1+ _LOGGER.error(+ "Failed to migrate %s out of www/: %s. File remains "+ "exposed via /local/yaml_backups/.",+ src.name,+ err,+ )++ try:+ legacy_dir.rmdir()+ except OSError as err:+ if err.errno not in (errno.ENOTEMPTY, errno.EEXIST):+ _LOGGER.warning(+ "Could not remove legacy backup dir %s: [%s] %s",+ legacy_dir,+ type(err).__name__,+ err,+ )++ return moved, failed++diff --git a/src/ha_mcp/tools/tools_yaml_config.py b/src/ha_mcp/tools/tools_yaml_config.pyindex 50d774f7..2a086b1c 100644--- a/src/ha_mcp/tools/tools_yaml_config.py+++ b/src/ha_mcp/tools/tools_yaml_config.py@@ -169 +169 @@ def register_yaml_config_tools(mcp: Any, client: Any, **kwargs: Any) -> None:- "Backups are saved to www/yaml_backups/."+ "Backups are saved to .ha_mcp_tools_backups/."AI-assisted change caef20fb0cf79b4d80e27ea42d520a5425cbcd8f3c5e88a69cd1c6cd836a9e52 · Fix 039d8a6f3757addc0f4c87bb563a812df257fcc44d7945a68c8e5ad45501508c