187 files

This commit is contained in:
Home Assistant Version Control committed 2026-09-03 17:54:35 +00:00
1 parent 16c9b4d7f4
commit d6413e19b5
187 files changed
+15542 -2765

No files matched your search

@@ -9,7 +9,7 @@ from pathlib import Path
import shutil
import tempfile
from typing import Any, NotRequired, TypedDict, cast
from urllib.parse import urlsplit
from urllib.parse import SplitResult, unquote, urlsplit
import aiohttp
from aiohttp import ClientError
@@ -39,10 +39,30 @@ TIMEOUT_SECONDS = 30
MODEL_JSON_RETRY_LIMIT = 2
ALLOWED_RESOURCE_HOSTS = frozenset({"github.com", "raw.githubusercontent.com"})
# Profile resources are only ever served from the profile library of the Powercalc repository.
# The download API names the URL for every file, so without this the API could point an install
# at any repository on GitHub. The model hash cannot stand in for this check: it is a digest of
# the library.json metadata entry, not of the files that get downloaded.
LIBRARY_REPOSITORY_SEGMENTS = ("bramstroker", "homeassistant-powercalc")
LIBRARY_RESOURCE_DIRECTORY = "profile_library"
MAX_RESOURCE_SIZE = 10 * 1024 * 1024
MAX_MANIFEST_SIZE = 1024 * 1024
DOWNLOAD_CHUNK_SIZE = 64 * 1024
def _is_library_repository_url(parsed_url: SplitResult) -> bool:
"""Check that a resource URL addresses the profile library of the Powercalc repository."""
segments = [segment for segment in unquote(parsed_url.path).split("/") if segment]
if any(segment in {".", ".."} for segment in segments):
return False
repository_depth = len(LIBRARY_REPOSITORY_SEGMENTS)
if tuple(segments[:repository_depth]) != LIBRARY_REPOSITORY_SEGMENTS:
return False
return LIBRARY_RESOURCE_DIRECTORY in segments[repository_depth:]
def _validate_resource_url(url: object) -> str:
"""Validate that a resource URL points to an allowed HTTPS host."""
"""Validate that a resource URL points to the profile library on an allowed HTTPS host."""
if not isinstance(url, str):
raise ProfileDownloadError("Remote profile resource has an invalid URL")
@@ -54,6 +74,7 @@ def _validate_resource_url(url: object) -> str:
and parsed_url.username is None
and parsed_url.password is None
and parsed_url.port in (None, 443)
and _is_library_repository_url(parsed_url)
)
except ValueError as err:
raise ProfileDownloadError(f"Remote profile resource has an invalid URL: {url}") from err
@@ -63,6 +84,26 @@ def _validate_resource_url(url: object) -> str:
return url
async def _read_capped(response: aiohttp.ClientResponse, limit: int, description: str) -> bytes:
"""Read a response body, refusing anything larger than `limit` bytes.
Downloads land in memory before they are written, so an oversized response would otherwise
be able to exhaust the memory of the Home Assistant instance.
"""
too_large = ProfileDownloadError(f"{description} is larger than the maximum of {limit} bytes")
if response.content_length is not None and response.content_length > limit:
raise too_large
chunks: list[bytes] = []
size = 0
async for chunk in response.content.iter_chunked(DOWNLOAD_CHUNK_SIZE):
size += len(chunk)
if size > limit:
raise too_large
chunks.append(chunk)
return b"".join(chunks)
def _resolve_resource_path(storage_path: str, resource_path: object) -> Path:
"""Resolve and validate a resource path within the profile storage directory."""
if not isinstance(resource_path, str) or not resource_path or "\0" in resource_path:
@@ -612,7 +653,14 @@ class RemoteLoader(Loader):
async with session.get(endpoint, params={"hash": model_hash}) as resp:
if resp.status != 200:
raise ProfileDownloadError(f"Failed to download profile: {manufacturer}/{model}")
resources = await resp.json()
manifest = await _read_capped(resp, MAX_MANIFEST_SIZE, "Remote profile resource manifest")
try:
resources = json.loads(manifest)
except JSONDecodeError as err:
raise ProfileDownloadError(
f"Remote profile response is not valid JSON: {manufacturer}/{model}",
) from err
validated_resources = await self.hass.async_add_executor_job(
_validate_resources,
@@ -629,7 +677,7 @@ class RemoteLoader(Loader):
if resp.status != 200:
raise ProfileDownloadError(f"Failed to download github URL: {url}")
contents = await resp.read()
contents = await _read_capped(resp, MAX_RESOURCE_SIZE, f"Remote profile resource {url}")
downloaded_resources.append((contents, destination))
await self.hass.async_add_executor_job(_save_resources, downloaded_resources)