Skip to content

Commit b3f2089

Browse files
kingpanther13claude
andcommitted
fix(code-mode): close proxy-laundering, ..-traversal, and 5 other Patch76 review findings
Implements the 9-item plan in #854 (comment) addressing the CHANGES_REQUESTED review at #854 (review) on commit 11ba402. Blocker 1 — recursive-self-call guard bypassable via ha_call_write_tool ----------------------------------------------------------------------- Two-layer fix: * ``CategorizedSearchTransform`` gains an ``enable_code_mode: bool`` constructor parameter (default False, preserves prior behaviour for installations that aren't running code mode). When True, ``_rebuild_category_cache`` swaps ``get_tool_catalog(ctx)`` for ``_get_visible_tools(ctx)`` — the same FastMCP helper that ``BM25SearchTransform`` already uses. Pinned tools (including ``ha_manage_custom_tool``) drop out of ``_read_tools`` / ``_write_tools`` / ``_delete_tools``, so ``categorized_call`` for a pinned name falls through to the ``RESOURCE_NOT_FOUND`` branch rather than dispatching the underlying tool. ``server.py`` flips the flag on whenever ``settings.enable_code_mode`` is True. * ``_BLOCKED_TOOLS`` (sandbox-side defense in depth) now also includes the four search-transform synthetics: ``ha_search_tools``, ``ha_call_read_tool``, ``ha_call_write_tool``, ``ha_call_delete_tool``. Even if a future regression re-enables the proxy dispatch, sandbox code can't reach the laundering path. Direct calls to underlying tools by their real name (``call_tool("ha_get_history", ...)``) keep working — the block is on the synthetics only, so individual underlying tools can still be denylisted in the future without needing to rework the proxy. Blocker 2 — ``..`` traversal in ``_normalize_endpoint`` ------------------------------------------------------- httpx happily resolves ``base_url='http://ha:8123/api'`` + ``../auth/providers`` to ``http://ha:8123/auth/providers``, escaping the ``/api/`` prefix. After ``lstrip("/")`` and the optional ``api/`` strip, ``_normalize_endpoint`` now splits on ``/`` and rejects any segment exactly equal to ``..``. ``..bar`` (filename starting with two dots) and ``...`` (three dots) stay allowed — they aren't traversal segments, just unusual filenames. Verified against 12 endpoint cases covering all 4 traversal patterns plus prior security guards (absolute URL, protocol-relative, userinfo) still holding. Additional registry blocks (``_BLOCKED_WS_COMMANDS``) ----------------------------------------------------- Added 9 entries for floor / label / category registry mutations to match the existing area / device / entity coverage. Each has a wrapping MCP tool (``ha_config_set_floor`` / ``ha_config_set_label`` / ``ha_config_set_category``) so the same "force through the validated path" rationale applies. Additional event blocks (``_BLOCKED_HA_INTERNAL_EVENTS``) --------------------------------------------------------- Added ``script_finished`` (pairs with the existing ``script_started`` block) and ``logbook_entry`` (this event IS the documented logbook write API; spoofing injects fabricated rows directly into the user's primary investigation tool — data-integrity issue, not just attack surface). ``automation_triggered`` and ``call_service`` stay allowed — legit "verify my handler reacts" use cases and downstream consumers can already check event context for provenance. ``list_saved`` shape — nest under ``data.saved_tools`` ------------------------------------------------------ ``_SAVE_NAME_PATTERN`` accepts every key the *other* response shapes use (``result``, ``code``, ``justification``, ``saved_tool``, ``count``). A consumer doing ``r["data"]["result"]`` after a list_saved call would have gotten a saved-tool entry instead of a run-result. Fixed by nesting the dict under a stable ``saved_tools`` key. Updated ``test_list_saved_tools`` to pin the new shape. Log injection (``%s`` interpolation of LLM-controlled strings) -------------------------------------------------------------- Added ``_log_safe`` helper that replaces ``\r`` / ``\n`` / ``\t`` with spaces and truncates to 200 chars. Applied to the ``ha_manage_custom_tool invoked — justification: %s`` log line at ``tools_code.py:1151`` so an LLM-supplied ``"real reason\nFAKE_CRITICAL: …"`` cannot inject a synthetic second log line. Audit-log endpoint/type fields use ``%r`` already which escapes via repr(); no change needed there. The DEBUG-level ``code:\n%s`` line stays raw — multi-line is intentional (operator's primary forensic artefact) and the format string already opens a fresh line so there's nothing to inject into. Tests ----- * ``TestCodeModeAdditionalResourceLimits`` (3 tests) — memory / recursion / invocation-cap. Previous suite only covered timeout. * ``TestCodeModeNormalizeEndpointTraversal`` (1 parametrized test, 4 rows) — all 4 ``..`` traversal patterns reject. * ``TestCodeModeProxyLaunderingBlocked`` (1 parametrized test) — ``call_tool`` to each of the 4 search synthetics returns the ``AUTH_INSUFFICIENT_PERMISSIONS`` block. * ``test_list_saved_tools`` updated to verify new ``data.saved_tools[name]`` + ``data.count`` shape. * ``test_save_warning_rollback_shape`` (skipped) — placeholder noting that the unit suite covers the persistence-failure rollback; E2E coverage requires runtime filesystem poisoning the addon container model doesn't expose. Lint/mypy/ast-grep all clean. 24 unit tests still pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 3b43cc3 commit b3f2089

4 files changed

Lines changed: 403 additions & 22 deletions

File tree

src/ha_mcp/server.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -547,12 +547,19 @@ def _apply_tool_search(self) -> None:
547547
max_results=self.settings.tool_search_max_results,
548548
always_visible=pinned,
549549
search_tool_description=description,
550+
# Pinned tools must be excluded from the proxy's
551+
# category sets when code mode is on; otherwise sandbox
552+
# code can launder a recursive ``ha_manage_custom_tool``
553+
# invocation through ``ha_call_write_tool``. See the
554+
# docstring on ``_rebuild_category_cache``.
555+
enable_code_mode=self.settings.enable_code_mode,
550556
)
551557
)
552558
logger.info(
553-
"Tool search transform applied (%d pinned tools, max_results=%d)",
559+
"Tool search transform applied (%d pinned tools, max_results=%d, code_mode=%s)",
554560
len(pinned),
555561
self.settings.tool_search_max_results,
562+
self.settings.enable_code_mode,
556563
)
557564
except Exception:
558565
logger.exception("Failed to apply tool search transform")

src/ha_mcp/tools/tools_code.py

Lines changed: 107 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,45 @@
4646
# from disk on registration and persisted on every save_as / delete.
4747
_saved_tools: dict[str, dict[str, str]] = {}
4848

49-
# Tools that sandbox code must not call (prevents recursive self-invocation)
50-
_BLOCKED_TOOLS = frozenset({"ha_manage_custom_tool"})
49+
# Translation table for ``_log_safe`` — replaces CR / LF / TAB with a single
50+
# space so an LLM-controlled string interpolated into a log line via ``%s``
51+
# can't manufacture extra log records.
52+
_LOG_CONTROL_CHARS_MAP = str.maketrans({"\r": " ", "\n": " ", "\t": " "})
53+
54+
55+
def _log_safe(value: Any, max_len: int = 200) -> str:
56+
"""Return ``value`` flattened to a single line, safe for ``%s`` log interpolation.
57+
58+
Replaces ``\\r`` / ``\\n`` / ``\\t`` with spaces and truncates to
59+
``max_len`` characters. Used on user-controlled strings (``justification``,
60+
saved-tool ``name``, etc.) before they reach ``logger.info(..., %s, ...)``
61+
so a crafted input like ``"real reason\\nFAKE_CRITICAL: thing crashed"``
62+
cannot inject a second log line.
63+
"""
64+
text = str(value)
65+
if len(text) > max_len:
66+
text = text[:max_len]
67+
return text.translate(_LOG_CONTROL_CHARS_MAP)
68+
69+
70+
# Tools that sandbox code must not call. Includes ``ha_manage_custom_tool``
71+
# itself (prevents recursive self-invocation) plus the four synthetics that
72+
# the categorized-search transform exposes when ``ENABLE_TOOL_SEARCH=true``
73+
# (``ha_search_tools``, ``ha_call_{read,write,delete}_tool``). Without those
74+
# four entries, sandbox code could "launder" a recursive call as
75+
# ``call_tool("ha_call_write_tool", {"name": "ha_manage_custom_tool", ...})``
76+
# — the proxy would then dispatch the underlying tool and the in-sandbox
77+
# guard never fires. The architectural fix lives in
78+
# ``CategorizedSearchTransform`` (excludes pinned tools from category sets
79+
# when code mode is on); this set is the defense-in-depth that closes the
80+
# inner-call path even if the proxy is reachable some other way.
81+
_BLOCKED_TOOLS = frozenset({
82+
"ha_manage_custom_tool",
83+
"ha_search_tools",
84+
"ha_call_read_tool",
85+
"ha_call_write_tool",
86+
"ha_call_delete_tool",
87+
})
5188

5289
# Validation for save_as names
5390
_SAVE_NAME_PATTERN = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]{0,63}$")
@@ -99,6 +136,7 @@
99136
"service_executed",
100137
"automation_reloaded",
101138
"script_started",
139+
"script_finished",
102140
"homeassistant_start",
103141
"homeassistant_started",
104142
"homeassistant_stop",
@@ -111,6 +149,11 @@
111149
"category_registry_updated",
112150
"floor_registry_updated",
113151
"label_registry_updated",
152+
# ``logbook_entry`` is the documented logbook write API — the Logbook
153+
# integration consumes it to render rows. Sandbox code firing this
154+
# event would inject attacker-fabricated rows directly into the
155+
# user's primary investigation tool, which is a data-integrity issue.
156+
"logbook_entry",
114157
"lovelace_updated",
115158
"panels_updated",
116159
"themes_updated",
@@ -139,6 +182,20 @@
139182
"config/entity_registry/delete",
140183
"config/entity_registry/disable",
141184
"config/entity_registry/update",
185+
# Floor / label / category registries follow the same rationale as
186+
# area / device / entity above: each has a wrapping MCP tool
187+
# (``ha_config_set_floor``, ``ha_config_set_label``,
188+
# ``ha_config_set_category``) that performs invariant checks the
189+
# raw WS command skips.
190+
"config/floor_registry/create",
191+
"config/floor_registry/delete",
192+
"config/floor_registry/update",
193+
"config/label_registry/create",
194+
"config/label_registry/delete",
195+
"config/label_registry/update",
196+
"config/category_registry/create",
197+
"config/category_registry/delete",
198+
"config/category_registry/update",
142199
})
143200

144201

@@ -578,13 +635,19 @@ def _normalize_endpoint(endpoint: Any) -> str:
578635
path works whether the caller wrote ``"events"``, ``"/events"``, or
579636
``"/api/events"``.
580637
581-
Rejects anything that looks like an absolute URL or a userinfo
582-
injection: an ``://`` substring, a leading ``//`` (protocol-relative),
583-
or an ``@`` before the first ``/``. Without this the httpx client
584-
will dispatch the request to the absolute host *with the HA bearer
585-
token still attached*, leaking credentials to whoever the LLM was
586-
prompted to point at. The sandbox is supposed to be on-instance
587-
only.
638+
Rejects:
639+
640+
* Absolute URL forms — ``://``, leading ``//`` (protocol-relative),
641+
or ``@`` before the first ``/`` (userinfo). Without this httpx
642+
will dispatch the request to the absolute host *with the HA
643+
bearer token still attached*, leaking credentials.
644+
* ``..`` path segments — httpx happily resolves
645+
``base_url='http://ha:8123/api'`` + endpoint ``'../auth/providers'``
646+
to ``http://ha:8123/auth/providers``, escaping the ``/api/``
647+
prefix entirely. HA exposes other bearer-authenticated routes
648+
at root (``/auth/...``, ``/profile``, etc.) — every one of
649+
those becomes reachable from the sandbox unless we reject
650+
``..`` here.
588651
"""
589652
if not isinstance(endpoint, str):
590653
raise ValueError("endpoint must be a string path (e.g. '/states')")
@@ -601,6 +664,16 @@ def _normalize_endpoint(endpoint: Any) -> str:
601664
ep = endpoint.lstrip("/")
602665
if ep.startswith("api/"):
603666
ep = ep[4:]
667+
# ``..`` segments would let the sandbox escape the ``/api/`` prefix
668+
# via httpx URL resolution. Check after stripping so the comparison
669+
# is against actual path segments, and return the same error for
670+
# leading-, mid-, or trailing-position cases.
671+
for segment in ep.split("/"):
672+
if segment == "..":
673+
raise ValueError(
674+
"endpoint must not contain '..' path segments; "
675+
"the sandbox is restricted to /api/ routes"
676+
)
604677
return ep
605678

606679
async def _api_get(endpoint: str) -> Any:
@@ -992,16 +1065,26 @@ async def ha_manage_custom_tool(
9921065

9931066
# --- Mode: list saved tools ---
9941067
if list_saved:
1068+
# The saved-tools dict is nested under a stable ``saved_tools``
1069+
# key rather than spread directly under ``data`` because the
1070+
# name pattern (``^[a-zA-Z_][a-zA-Z0-9_]{0,63}$``) accepts
1071+
# values like ``result``, ``count``, ``code`` — every one of
1072+
# which is also a key the *other* response shapes use. A
1073+
# consumer reading ``r["data"]["result"]`` after a list_saved
1074+
# call would otherwise get a saved-tool entry instead of a
1075+
# run-result.
9951076
return {
9961077
"success": True,
9971078
"data": {
998-
name: {
999-
"code": info["code"],
1000-
"justification": info["justification"],
1001-
}
1002-
for name, info in _saved_tools.items()
1079+
"saved_tools": {
1080+
name: {
1081+
"code": info["code"],
1082+
"justification": info["justification"],
1083+
}
1084+
for name, info in _saved_tools.items()
1085+
},
1086+
"count": len(_saved_tools),
10031087
},
1004-
"count": len(_saved_tools),
10051088
}
10061089

10071090
# --- Mode: run saved tool ---
@@ -1086,7 +1169,15 @@ async def ha_manage_custom_tool(
10861169
)
10871170
)
10881171

1089-
logger.info("ha_manage_custom_tool invoked — justification: %s", justification[:200])
1172+
logger.info(
1173+
"ha_manage_custom_tool invoked — justification: %s",
1174+
_log_safe(justification),
1175+
)
1176+
# Code is logged at DEBUG and the multi-line shape is intentional
1177+
# (the LLM-authored snippet is the operator's primary forensic
1178+
# artefact). No control-char sanitisation here because the log
1179+
# format string already opens a fresh line — there's nothing to
1180+
# inject into.
10901181
logger.debug("ha_manage_custom_tool code:\n%s", code)
10911182

10921183
try:

src/ha_mcp/transforms/categorized_search.py

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,7 @@ def __init__(
166166
call_read_name: str = "ha_call_read_tool",
167167
call_write_name: str = "ha_call_write_tool",
168168
call_delete_name: str = "ha_call_delete_tool",
169+
enable_code_mode: bool = False,
169170
**kwargs: Any,
170171
) -> None:
171172
super().__init__(
@@ -183,6 +184,15 @@ def __init__(
183184
self._call_delete_name = call_delete_name
184185
self._search_tool_description = search_tool_description
185186
self._proxy_descs = _build_proxy_descriptions(search_tool_name)
187+
# When code mode is enabled, the proxy must NOT dispatch to pinned
188+
# tools (specifically ``ha_manage_custom_tool``) — otherwise a
189+
# sandbox call to ``ha_call_write_tool`` with name=
190+
# "ha_manage_custom_tool" would launder a recursive invocation
191+
# past ``_BLOCKED_TOOLS`` inside the sandbox. Default False
192+
# preserves existing behaviour for installations that aren't
193+
# running code mode; server.py flips this on when
194+
# ``settings.enable_code_mode`` is True.
195+
self._enable_code_mode = enable_code_mode
186196

187197
# Category caches rebuilt when the catalog hash changes,
188198
# matching BM25SearchTransform's staleness detection pattern.
@@ -201,8 +211,21 @@ def _catalog_hash(tools: Sequence[Tool]) -> str:
201211
return hashlib.sha256(key.encode()).hexdigest()
202212

203213
async def _rebuild_category_cache(self, ctx: Any) -> None:
204-
"""Rebuild the read/write/delete category sets if catalog changed."""
205-
catalog = await self.get_tool_catalog(ctx)
214+
"""Rebuild the read/write/delete category sets if catalog changed.
215+
216+
When ``self._enable_code_mode`` is True, pinned tools are excluded
217+
from the category sets via ``_get_visible_tools`` (the same
218+
FastMCP helper that ``BM25SearchTransform`` uses). This prevents
219+
a sandbox-side recursive invocation laundered as
220+
``ha_call_write_tool(name="ha_manage_custom_tool", ...)`` —
221+
without the filter, the pinned-and-callable
222+
``ha_manage_custom_tool`` ends up in ``_write_tools`` and the
223+
proxy will happily dispatch.
224+
"""
225+
if self._enable_code_mode:
226+
catalog = await self._get_visible_tools(ctx)
227+
else:
228+
catalog = await self.get_tool_catalog(ctx)
206229
current_hash = self._catalog_hash(catalog)
207230
if current_hash == self._last_catalog_hash:
208231
return

0 commit comments

Comments
 (0)