291 files

This commit is contained in:
Home Assistant Version Control
2026-10-14 18:10:18 +00:00
parent c4e0178d8a
commit 280501d2e8
291 changed files with 32540 additions and 3280 deletions
+41 -17
View File
@@ -47,7 +47,9 @@ from aiohttp import web
from homeassistant.components.http import HomeAssistantView
from homeassistant.components.webhook import async_register, async_unregister
from homeassistant.core import HomeAssistant
from homeassistant.util.aiohttp import MockRequest
from .cloudhook import OAUTH_UNAVAILABLE, buffered_response
from .const import (
DATA_WEBHOOK,
DATA_WEBHOOK_ID,
@@ -124,6 +126,13 @@ _ALLOWED_CONTENT_TYPES = ("application/json", "text/event-stream", "text/plain")
# long-lived streams fails a new request in 30 s instead of hanging it forever.
_CLIENT_TIMEOUT = aiohttp.ClientTimeout(connect=30, sock_connect=10, sock_read=300)
# The in-process server closes an idle keep-alive connection after 5 s
# (``const.SERVER_KEEPALIVE_SECONDS``); aiohttp pools one for 15 s. A
# request sent as the server closes a pooled connection fails with a connection
# reset (502), so idle relay connections are dropped first.
_RELAY_KEEPALIVE_SECONDS = 3
# Anonymous CIMD lookups get a separate, deliberately small connection pool.
# The relay session may hold long-lived SSE connections; public metadata fetches
# must never consume that authenticated forwarding capacity.
@@ -154,7 +163,9 @@ def _build_base_url(request: web.Request) -> str:
hardening.
"""
host = request.headers.get("X-Forwarded-Host") or request.headers.get("Host", "")
scheme = request.headers.get("X-Forwarded-Proto", request.scheme)
scheme = request.headers.get(
"X-Forwarded-Proto", getattr(request, "scheme", "https")
)
return f"{scheme}://{host}"
@@ -429,8 +440,7 @@ class _AuthorizationServerMetadataView(HomeAssistantView):
class _WellKnownProtectedResourceView(HomeAssistantView):
"""RFC 9728 §3.1 path-scoped Protected Resource Metadata — the ONLY
protected-resource document this integration serves.
"""RFC 9728 §3.1 metadata for the webhook and its read-only alias.
Served at the well-known location derived from the webhook resource URL
(``/.well-known/oauth-protected-resource/api/webhook/<id>``), which is also
@@ -460,15 +470,17 @@ class _WellKnownProtectedResourceView(HomeAssistantView):
def __init__(self, hass: HomeAssistant) -> None:
"""Bind the view to the HA instance; liveness is resolved per request."""
self._hass = hass
self.extra_urls = [f"{self.url}/readonly"]
async def get(self, request: web.Request, webhook_id: str) -> web.Response:
"""Serve the document only for the CURRENT entry's webhook id."""
active_id = _active_webhook_id(self._hass)
if active_id is None or webhook_id != active_id:
return _json_not_found()
return web.json_response(
_protected_resource_document(active_id, _build_base_url(request))
)
document = _protected_resource_document(active_id, _build_base_url(request))
if request.path == f"{self.url.format(webhook_id=webhook_id)}/readonly":
document["resource"] += "/readonly"
return web.json_response(document)
class _WellKnownAuthorizationServerMetadataView(_AuthorizationServerMetadataView):
@@ -542,13 +554,20 @@ def _register_metadata_views(hass: HomeAssistant) -> None:
hass.data[_OAUTH_VIEWS_REGISTERED_KEY] = True
def _build_unauthorized_response(request: web.Request, webhook_id: str) -> web.Response:
def _build_unauthorized_response(
request: web.Request, cfg: dict[str, Any]
) -> web.Response:
"""Build the 401 + ``WWW-Authenticate`` challenge MCP clients use to discover.
Per RFC 9728 §5.1 / MCP 2026-07-28 Authorization Server Discovery, the
``resource_metadata`` parameter points to the protected-resource metadata
URL where the client finds the authorization server.
URL where the client finds the authorization server. Home Assistant Cloud
relays only ``Content-Type`` back from a cloudhook, so the challenge can
never reach that client and sign-in cannot start: say so instead (#2696).
"""
webhook_id = cfg["webhook_id"]
if isinstance(request, MockRequest):
return web.Response(status=400, text=OAUTH_UNAVAILABLE)
base = _build_base_url(request)
# RFC 9728 §3.1 path-scoped location. The pointer names the id, but this
# 401 is only produced on a request TO /api/webhook/<id>, so the caller
@@ -557,6 +576,8 @@ def _build_unauthorized_response(request: web.Request, webhook_id: str) -> web.R
metadata_url = (
f"{base}/.well-known/oauth-protected-resource/api/webhook/{webhook_id}"
)
if getattr(request, "path", "") == f"/api/webhook/{webhook_id}/readonly":
metadata_url += "/readonly"
return web.Response(
status=401,
text="Unauthorized",
@@ -589,10 +610,10 @@ async def _check_webhook_auth(
if resource_server is not None and not await resource_server.validate_request(
request
):
return _build_unauthorized_response(request, cfg["webhook_id"])
return _build_unauthorized_response(request, cfg)
oauth_provider: LegacyOAuthProvider | None = cfg.get("oauth_provider")
if oauth_provider is not None and not oauth_provider.validate_bearer(request):
return _build_unauthorized_response(request, cfg["webhook_id"])
return _build_unauthorized_response(request, cfg)
return None
@@ -618,7 +639,10 @@ async def _async_handle_webhook(
)
session: aiohttp.ClientSession = cfg["session"]
body = await request.read()
# A cloudhook (#2696) arrives as ``MockRequest``: no ``read()``, no transport,
# and the relay returns only ``response.body`` — so buffer, never stream.
cloudhook = isinstance(request, MockRequest)
body = await (request.content.read() if cloudhook else request.read())
forward_headers = {
key: value
@@ -643,7 +667,7 @@ async def _async_handle_webhook(
if mcp_session:
resp_headers["Mcp-Session-Id"] = mcp_session
if "text/event-stream" in content_type:
if "text/event-stream" in content_type and not cloudhook:
# SSE streaming: prevent HA's compression middleware from
# buffering/breaking the stream (supervisor#6470).
resp_headers["Content-Type"] = "text/event-stream"
@@ -677,10 +701,7 @@ async def _async_handle_webhook(
if not any(ct in content_type for ct in _ALLOWED_CONTENT_TYPES):
content_type = "application/json"
resp_headers["Content-Type"] = content_type
resp_body = await upstream_resp.read()
return web.Response(
status=upstream_resp.status, body=resp_body, headers=resp_headers
)
return await buffered_response(cloudhook, upstream_resp, resp_headers)
except aiohttp.ClientError as err:
_LOGGER.error("MCP webhook: upstream request failed: %s", err)
return web.Response(status=502, text="MCP server unavailable")
@@ -844,7 +865,10 @@ async def async_register_webhook(
# Runs before the session opens so a raise here cannot leak it.
_unregister_webhook(hass, webhook_id)
target_url = f"http://127.0.0.1:{port}{secret_path}"
session = aiohttp.ClientSession(timeout=_CLIENT_TIMEOUT)
session = aiohttp.ClientSession(
timeout=_CLIENT_TIMEOUT,
connector=aiohttp.TCPConnector(keepalive_timeout=_RELAY_KEEPALIVE_SECONDS),
)
cimd_session: aiohttp.ClientSession | None = None
if register_endpoint and auth_mode == WEBHOOK_AUTH_HA:
try: