Root cause
Authentication & access control introduced
fix: add ingress_stream, serve settings at root for ingress
How AI contributed: Direct introductionPublished Jul 7, 2026 in
homeassistant-ai/ha-mcpAll 4 findings in this repository
Summary In add-on mode, the ha-mcp settings UI routes are mounted both under the MCP secret path and at the bare root of the published port (:9583), so Home Assistant ingress can serve the "Open Web UI" button. The root-mounted routes perform no authentication — no secret, no Origin check, no CSRF token — so any client that can reach :9583 without the MCP secret can invoke them. Affected configurations Home Assistant add-on installations (host_network: true with port 9583 published), v7.6.0 and earlier. Docker and standalone installs are not affected — there the settings routes are mounted only under the secret path. Root-mounted routes in affected versions: tool visibility (/api/settings/tools GET/POST), feature flags (/api/settings/features GET/POST), the auto-backup suite (/api/settings/backups… incl. restore/delete, and /api/settings/backup-config), add-on restart (/api/settings/restart), and — when the opt-in Tool Security Policies feature is enabled — the approval-policy API (/api/policy/config GET/PUT, /api/policy/approve, /api/policy/deny, …). Impact Without authentication, a caller that reaches :9583 — a peer on the local network, a reverse proxy/tunnel that forwards the bare root path (e.g. a whole-host Cloudflared config), or a CSRF POST from a page open in a LAN browser — can read or change which MCP tools are exposed, toggle feature flags, list/view/restore/delete backups, restart the add-on, and (with Tool Security Policies enabled) read and rewrite the approval policy, disabling the human-approval gate on gated tools. There is no access to Home Assistant data, entities, or credentials, and no code execution. All effects are confined to the add-on's own configuration and lifecycle and are recoverable. The primary (same-LAN) vector is within the add-on's documented trusted-network model; remote reachability requires the operator to have reverse-proxied the bare port. Proof of concept With the add-on running and reachable on :9583, from any host that can reach the port without the secret: The MCP endpoint itself remains correctly protected by the secret path. Patch Fixed in PR homeassistant-ai/ha-mcp#1508 (merged to master): the root-mounted add-on routes are restricted to Home Assistant ingress, which always originates from the Supervisor (172.30.32.2); every other caller receives 403. Direct and remote access continue to use the settings UI under the MCP secret path (…/<secret>/settings), so the "Open Web UI" button, Cloudflared, and the Webhook Proxy add-on are unaffected. The fix will ship in the next stable add-on release. If you'd rather have it now, it is already on the dev channel (add-on dev build 7.6.0.dev393 or later) — optional; there's no need to switch channels just for this, it is a fairly low risk surface and only exposes the web UI for addon mode only. Severity CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:L = 6.4 (Moderate). Confidentiality impact is None — tool config and backups are not secrets or credentials; integrity and availability impacts are Low — configuration changes and an add-on restart are recoverable. Credit Reported by @bharat.
In add-on mode settings_ui.py served the settings routes both under the MCP secret path and at the bare root of the published port, and the root-mounted handlers ran no secret, Origin or CSRF check. The fix wraps each root route with a transport peer check that answers any client other than the Supervisor ingress address with a 403.
Root cause
fix: add ingress_stream, serve settings at root for ingress
Fix
fix(addon): restrict settings UI root routes to HA ingress (#1508)
AI-assisted fix: Claude Code · kingpanther13
Case facts
@@ -415,6 +415,10 @@ def register_settings_routes( ) -> None: """Register the /settings web UI and /api/settings/* endpoints.""" + @mcp.custom_route("/", methods=["GET"])+ async def _root_page(_: Request) -> HTMLResponse:+ return HTMLResponse(_SETTINGS_HTML)+ @mcp.custom_route("/settings", methods=["GET"]) async def _settings_page(_: Request) -> HTMLResponse: return HTMLResponse(_SETTINGS_HTML)@@ -13,7 +13,7 @@ startup: application boot: manual ingress: true ingress_port: 9583-panel_icon: mdi:robot+ingress_stream: true # Enable access to Supervisor API for auto-discovery hassio_api: true hassio_role: default@@ -11,17 +11,19 @@ import asyncio import contextlib+import functools import json import logging import os import time import uuid+from collections.abc import Awaitable, Callable from pathlib import Path from typing import TYPE_CHECKING, Any, Literal, NamedTuple, NotRequired, TypedDict import httpx from starlette.requests import Request-from starlette.responses import HTMLResponse, JSONResponse+from starlette.responses import HTMLResponse, JSONResponse, Response from ._version import get_version, is_running_in_addon from .backup_manager import get_backup_manager@@ -2956,6 +2958,65 @@ async def _save_backup_config(request: Request) -> JSONResponse: return handlers +# Home Assistant proxies every ingress request ("Open Web UI") from the+# Supervisor's fixed network address. Per the add-on ingress contract+# (https://developers.home-assistant.io/docs/add-ons/presentation/#ingress —+# "Only connections from 172.30.32.2 must be allowed") the app must reject+# every other source. This holds under host_network too: ingress proxies to+# http://{app.ip_address}:{ingress_port}/, and for a host-network add-on+# app.ip_address is the hassio bridge gateway 172.30.32.1 — the DESTINATION the+# Supervisor dials (supervisor/docker/app.py ip_address(): host_network ->+# network.gateway). The Supervisor opens that connection from its own container+# address 172.30.32.2, so the transport peer the add-on sees is 172.30.32.2 for+# genuine ingress and some other address (a LAN host, the cloudflared tunnel at+# 172.30.33.x, another add-on) for a direct port-9583 hit. Verified live via+# netstat during an "Open Web UI" click.+SUPERVISOR_INGRESS_IP = "172.30.32.2"++# A settings-UI route handler: async (Request) -> Response.+_SettingsRoute = Callable[[Request], Awaitable[Response]]+++def _ingress_only(handler: _SettingsRoute) -> _SettingsRoute:+ """Wrap a root-mounted add-on route so only HA ingress can reach it.++ Add-on root routes carry no MCP secret, so without this guard a direct+ caller on the published port — a LAN peer, a reverse proxy / tunnel+ forwarding the bare root, or a CSRF POST from a LAN browser — could+ rewrite tool config, flip the tool-security-policy, or restart the+ add-on with no authentication. We gate on the *transport* peer+ (``request.client.host``), never ``X-Forwarded-For`` (which a caller can+ forge). The same handlers stay reachable under ``secret_prefix``, where+ the MCP secret path is the auth for direct/remote access.+ """++ @functools.wraps(handler)+ async def _guarded(request: Request) -> Response:+ peer = request.client.host if request.client else None+ if peer != SUPERVISOR_INGRESS_IP:+ logger.warning(+ "Blocked non-ingress request to add-on root route %s from "+ "peer %r (only the Supervisor at %s may reach root routes; "+ "use the MCP secret path for direct/remote access).",+ request.url.path,+ peer,+ SUPERVISOR_INGRESS_IP,+ )+ return JSONResponse(+ {+ "error": (+ "This endpoint is only reachable through Home "+ "Assistant ingress. For direct or remote access, use "+ "the settings UI under your MCP secret path."+ )+ },+ status_code=403,+ )+ return await handler(request)++ return _guarded++ def register_settings_routes( mcp: FastMCP, server: HomeAssistantSmartMCPServer,@@ -3030,18 +3091,26 @@ def register_settings_routes( ("/api/policy/value-source", ["GET"], "policy_get_value_source"), ]@@ -21,9 +21,11 @@ _SETTINGS_JS, FEATURE_GATED_TOOLS, MANDATORY_TOOLS,+ SUPERVISOR_INGRESS_IP, TRANSFORM_GENERATED_TOOLS, _get_config_path, _get_tool_metadata,+ _ingress_only, apply_tool_visibility, load_tool_config, register_settings_routes,@@ -517,7 +519,11 @@ def _capture_handler(self, monkeypatch) -> SaveHandler: def custom_route_factory(path, methods): def decorator(fn):- if path == "/api/settings/tools" and "POST" in methods:+ # Match both the root and secret-path mounts; the secret mount+ # registers last, so `captured["save"]` ends up the unguarded+ # handler (the root mount is wrapped in _ingress_only). This+ # test exercises body validation, not the ingress gate.+ if path.endswith("/api/settings/tools") and "POST" in methods: captured["save"] = fn return fn @@ -3228,3 +3234,164 @@ async def test_save_features_master_off_applied_dict_contains_only_master( assert sub not in applied get_data_dir.cache_clear() _reset_global_settings()+++def _http_request(+ peer_ip: str | None,+ path: str = "/api/policy/config",+ headers: list[tuple[bytes, bytes]] | None = None,+) -> Request:+ """Build a minimal Starlette Request with a controllable transport peer.++ ``client`` is the ASGI peer tuple uvicorn fills from the real TCP+ connection — the transport-level signal the ingress guard checks (the real+ TCP peer, not ``X-Forwarded-For``-forgeable). ``None`` models a connection+ with no peer info. ``headers`` lets a test attach request headers (e.g. a+ forged ``X-Forwarded-For``) to prove they cannot influence the gate.+ """+ scope = {+ "type": "http",+ "method": "GET",+ "path": path,+ "headers": headers or [],+ "query_string": b"",+ "client": (peer_ip, 51234) if peer_ip is not None else None,+ }+ return Request(scope)+++class TestIngressOnlyGuard:+ """`_ingress_only` admits only the Supervisor (HA ingress) source IP."""++ @pytest.mark.asyncio+ async def test_blocks_non_supervisor_peer(self):+ called = False++ async def inner(_request):+ nonlocal called+ called = True+ return JSONResponse({"ok": True})++ resp = await _ingress_only(inner)(_http_request("192.168.1.50"))+ assert resp.status_code == 403+ # The wrapped handler must never run for a rejected peer.+ assert called is False++ @pytest.mark.asyncio+ async def test_allows_supervisor_peer(self):+ async def inner(_request):+ return JSONResponse({"ok": True}, status_code=200)++ resp = await _ingress_only(inner)(_http_request(SUPERVISOR_INGRESS_IP))+ assert resp.status_code == 200+ assert json.loads(resp.body) == {"ok": True}++ @pytest.mark.asyncio+ async def test_blocks_missing_client(self):+ async def inner(_request):+ return JSONResponse({"ok": True})++ resp = await _ingress_only(inner)(_http_request(None))+ assert resp.status_code == 403++ @pytest.mark.asyncioAI-assisted change 33ab30bdbbe832915530c4c97bbf231d8a14d281b631349956e21b6c0acf09fe · Fix 5ba593ab4b1da8cf13aade245c35f3fdad71533755e96b14bc0db59b21c09c11