Skip to content

Commit 5e02d18

Browse files
fix(webhook-proxy): surface webhook registration failures instead of silently loading (#1101)
* fix(webhook-proxy): surface webhook registration failures instead of silently loading Defect 2 of #1020: when /config/.mcp_proxy_config.json contains a corrupted target_url (e.g. the secret-path truncation triggered by Defect 1), the mcp_proxy custom integration would proceed past its empty-string guard, register with the truncated URL, and report state="loaded" while every webhook request returned 404 from HA core. Two changes in async_setup_entry: - Validate target_url shape before registering. The path must match /private_<token> with token >= 16 chars (real ha-mcp generates a 22-char base64url token; the truncation bug yielded ~7). On failure, raise ConfigEntryError(reason) so HA surfaces the reason in the UI and config-entries API instead of returning False. - Wrap async_register() in try/except. On failure, close the aiohttp session and re-raise as ConfigEntryError with the original error as cause. Move hass.data[DOMAIN] assignment to after a successful register so a failed setup doesn't leave half-state. Tests cover the issue's exact reproducer (/private_ZZZZZZZ truncation), all rejection paths, async_register raising, and the happy path. They stub homeassistant.* and aiohttp via sys.modules so the integration can be imported under our existing pytest harness without pulling HA Core into dev deps. Defect 1 (the parser truncation itself) is addressed separately via PR #952, which persists the secret path to addon options so the regex fallback isn't reached in practice. * fix(webhook-proxy): close additional silent-failure gaps and tighten validator Address PR-review findings on top of the original Defect 2 fix: - _read_config now lets OSError/JSONDecodeError propagate instead of swallowing them. async_setup_entry catches them and raises ConfigEntryError so a corrupted /config/.mcp_proxy_config.json no longer masquerades as a "no config yet, fresh install" state. - _validate_target_url now also rejects URLs containing query strings, fragments, or path parameters. urlparse strips these from parsed.path, so previously something like /private_<token>?x=y validated clean but the addon would 404 on the forwarded request. - Drop the dead try/except ValueError around urlparse — it doesn't raise on any string input. - Validator failure reasons no longer echo parsed.path verbatim, which was leaking the raw secret token into ERROR-level logs even when the caller's masked_target was correct. Caught by the new caplog test. - Reconcile the regex-vs-comment mismatch: comment said "22-char token", regex enforced >=16. Reworded as "real tokens are 22 chars; we accept >=16 as a sanity floor" to match what the code actually does. Tests: - TestUnloadEntry — async_unload_entry was untested. Two cases now pin that unload after a failed setup is a no-op (no async_unregister, no double-close) and that unload after a successful setup unregisters and closes the session. - test_truncated_url_does_not_log_full_token — caplog assertion that catches the secret-leak in validator reasons. - test_corrupted_json_raises_config_entry_error / test_unreadable_config_raises_config_entry_error — pin the new behavior that distinguishes "no file" from "broken file". - test_register_failure_closes_session_and_raises is now parametrized over RuntimeError/ValueError/KeyError (HA's documented failure modes for async_register), documenting that the broad except catch is intentional. - TestTargetUrlValidation gains 15-char boundary, query, fragment, and path-params rejection tests. - All early-fail paths now also assert ClientSession is never constructed so a future "early connect probing" refactor can't silently leak resources. Bump both the addon (config.yaml: 1.0.1 -> 1.0.2) and the bundled mcp_proxy integration (manifest.json: 1.0.0 -> 1.0.2). The two had diverged because an earlier addon-only bump didn't touch the integration; aligning both to 1.0.2 going forward since they always ship together. --------- Co-authored-by: kingpanther13 <kingpanther13@users.noreply.github.com>
1 parent 22ef1c6 commit 5e02d18

4 files changed

Lines changed: 446 additions & 20 deletions

File tree

homeassistant-addon-webhook-proxy/config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
name: "Nabu Casa / Webhook Proxy for HA MCP"
22
description: "Remote access proxy via Nabu Casa or any reverse proxy (Cloudflare, DuckDNS, nginx)"
3-
version: "1.0.1"
3+
version: "1.0.2"
44
slug: "ha_mcp_webhook_proxy"
55
url: "https://github.com/homeassistant-ai/ha-mcp"
66
arch:

homeassistant-addon-webhook-proxy/mcp_proxy/__init__.py

Lines changed: 89 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@
1212

1313
import json
1414
import logging
15+
import re
1516
from pathlib import Path
17+
from urllib.parse import urlparse
1618

1719
import aiohttp
1820
from aiohttp import web
@@ -22,13 +24,44 @@
2224
)
2325
from homeassistant.config_entries import ConfigEntry
2426
from homeassistant.core import HomeAssistant
27+
from homeassistant.exceptions import ConfigEntryError
2528
from homeassistant.helpers.typing import ConfigType
2629

2730
_LOGGER = logging.getLogger(__name__)
2831

2932
DOMAIN = "mcp_proxy"
3033
CONFIG_FILE = Path("/config/.mcp_proxy_config.json")
3134

35+
# ha-mcp generates a 22-char base64url token after `/private_`. We accept >=16
36+
# as a sanity floor — a truncated/corrupted ha-mcp config yields a shorter
37+
# token, which is the failure mode this length check exists to catch.
38+
_SECRET_PATH_RE = re.compile(r"^/private_[A-Za-z0-9_-]{16,}$")
39+
40+
41+
def _validate_target_url(target_url: str) -> tuple[bool, str]:
42+
"""Check that target_url is a well-formed http(s) URL.
43+
44+
When the path starts with `/private_` we additionally enforce the
45+
ha-mcp secret-path shape so a truncated token (the issue we're guarding
46+
against) is rejected. Other paths are accepted as-is — users with a
47+
custom MCP server pointed at a different path are not constrained.
48+
"""
49+
parsed = urlparse(target_url)
50+
51+
if parsed.scheme not in ("http", "https"):
52+
return False, f"scheme must be http or https, got {parsed.scheme!r}"
53+
if not parsed.netloc:
54+
return False, "URL is missing host"
55+
if parsed.params or parsed.query or parsed.fragment:
56+
return False, "URL must not contain query, fragment, or path parameters"
57+
if parsed.path.startswith("/private_") and not _SECRET_PATH_RE.match(parsed.path):
58+
# Don't echo parsed.path — it contains the (truncated) secret token.
59+
return False, (
60+
"secret path is too short or malformed "
61+
"(expected /private_<token> with token of at least 16 characters)"
62+
)
63+
return True, ""
64+
3265

3366
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
3467
"""Set up the MCP Webhook Proxy from configuration.yaml (migration only).
@@ -51,7 +84,15 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
5184

5285
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
5386
"""Set up MCP Webhook Proxy from a config entry."""
54-
proxy_config = await hass.async_add_executor_job(_read_config)
87+
try:
88+
proxy_config = await hass.async_add_executor_job(_read_config)
89+
except (OSError, json.JSONDecodeError) as err:
90+
_LOGGER.error("MCP Proxy: Failed to read %s: %s", CONFIG_FILE, err)
91+
raise ConfigEntryError(
92+
f"Failed to read {CONFIG_FILE}: {err}. Restart the Webhook Proxy "
93+
"addon to regenerate the config file."
94+
) from err
95+
5596
if proxy_config is None:
5697
_LOGGER.info(
5798
"MCP Proxy: No config found at %s. "
@@ -65,47 +106,78 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
65106

66107
if not target_url or not webhook_id:
67108
_LOGGER.error("MCP Proxy: Invalid config - missing target_url or webhook_id")
68-
return False
109+
raise ConfigEntryError(
110+
"Missing target_url or webhook_id in /config/.mcp_proxy_config.json. "
111+
"Restart the Webhook Proxy addon to regenerate it."
112+
)
69113

70114
# Mask sensitive values in logs to avoid leaking secrets
71115
if "/private_" in target_url:
72116
masked_target = target_url.split("/private_")[0] + "/private_********"
73117
else:
74118
masked_target = target_url
75119
masked_wh = webhook_id[:6] + "..." if len(webhook_id) > 6 else "***"
120+
121+
# Validate target_url shape before registering. Without this, a corrupted
122+
# URL (e.g. a truncated secret-path) propagates silently and the config
123+
# entry reports `loaded` while every webhook request returns 404.
124+
is_valid, reason = _validate_target_url(target_url)
125+
if not is_valid:
126+
_LOGGER.error(
127+
"MCP Proxy: target_url validation failed for %s: %s",
128+
masked_target,
129+
reason,
130+
)
131+
raise ConfigEntryError(
132+
f"Invalid target_url ({reason}). Restart the Webhook Proxy addon "
133+
"to regenerate /config/.mcp_proxy_config.json."
134+
)
135+
76136
_LOGGER.info("MCP Proxy: target = %s", masked_target)
77137
_LOGGER.info("MCP Proxy: webhook endpoint = /api/webhook/%s", masked_wh)
78138

79139
session = aiohttp.ClientSession(
80140
timeout=aiohttp.ClientTimeout(total=300, sock_connect=10, sock_read=300),
81141
)
142+
143+
try:
144+
async_register(
145+
hass,
146+
DOMAIN,
147+
"MCP Proxy",
148+
webhook_id,
149+
_handle_webhook,
150+
allowed_methods=["POST", "GET"],
151+
)
152+
except Exception as err:
153+
_LOGGER.exception(
154+
"MCP Proxy: failed to register webhook endpoint /api/webhook/%s",
155+
masked_wh,
156+
)
157+
await session.close()
158+
raise ConfigEntryError(
159+
f"Failed to register webhook endpoint: {err}"
160+
) from err
161+
82162
hass.data[DOMAIN] = {
83163
"target_url": target_url,
84164
"webhook_id": webhook_id,
85165
"session": session,
86166
}
87167

88-
async_register(
89-
hass,
90-
DOMAIN,
91-
"MCP Proxy",
92-
webhook_id,
93-
_handle_webhook,
94-
allowed_methods=["POST", "GET"],
95-
)
96-
97168
return True
98169

99170

100171
def _read_config() -> dict | None:
101-
"""Read proxy config from JSON file (blocking I/O)."""
172+
"""Read proxy config from JSON file (blocking I/O).
173+
174+
Returns None only when the file does not exist (fresh install). Read or
175+
parse errors propagate as OSError/JSONDecodeError so the caller can
176+
distinguish "no config yet" from "config is corrupted".
177+
"""
102178
if not CONFIG_FILE.exists():
103179
return None
104-
try:
105-
return json.loads(CONFIG_FILE.read_text())
106-
except (OSError, json.JSONDecodeError) as e:
107-
_LOGGER.error("MCP Proxy: Failed to read %s: %s", CONFIG_FILE, e)
108-
return None
180+
return json.loads(CONFIG_FILE.read_text())
109181

110182

111183
async def _handle_webhook(

homeassistant-addon-webhook-proxy/mcp_proxy/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"domain": "mcp_proxy",
33
"name": "MCP Webhook Proxy",
44
"documentation": "https://github.com/homeassistant-ai/ha-mcp",
5-
"version": "1.0.0",
5+
"version": "1.0.2",
66
"codeowners": [],
77
"config_flow": true,
88
"dependencies": ["webhook"],

0 commit comments

Comments
 (0)