mirror of
https://github.com/smkrv/ha-text-ai.git
synced 2026-07-22 07:03:58 +08:00
fix: v2.5.1 security/ML/reliability patch
Security: - H1 DNS rebinding TOCTOU: validate_endpoint returns (endpoint, resolved_ips); create_pinned_session builds an aiohttp session with a custom resolver that returns only the pre-validated IPs, closing the re-resolve gap. - H2/M3 Shared session cookie pollution: integration no longer uses HA shared clientsession; isolated session with DummyCookieJar prevents cross-integration cookie leaks. - Cloud metadata / link-local block added to allow_local_network mode to prevent IMDS exfiltration on cloud VMs. - L3 hard cap extended to history.async_get_history (not only service schema). ML/LLM correctness: - _is_openai_reasoning_model uses regex with gpt-5-chat* blacklist and handles OpenRouter-style openai/ prefix. Future o5/gpt-6 auto-recognized. - reasoning_effort=minimal for gpt-5 family, low for o-series. - Gemini 2.5 Pro gets thinking_budget=128 (Pro rejects 0, flash accepts 0). - Anthropic extracts first type=text block instead of hardcoded content[0]. - /no_think dedup uses word-boundary regex instead of substring. - DeepSeek-reasoner detected: skips /no_think; preserves reasoning_content. Reliability: - Exception chaining added to Gemini-block re-raises. - Top-level imports for json/re/hashlib (no more lazy stdlib imports). - allow_local_network logged at INFO, not WARNING on every setup. Code style: - PEP 604 type hints across all .py files (dict/list/| None). No breaking changes for users. Internal API: validate_endpoint return type changed from str to tuple[str, list[str]].
This commit is contained in:
@@ -8,14 +8,22 @@ Utility functions for HA Text AI integration.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import ipaddress
|
||||
import logging
|
||||
import socket
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import aiohttp
|
||||
from aiohttp.abc import AbstractResolver
|
||||
from aiohttp.resolver import DefaultResolver
|
||||
|
||||
from homeassistant.const import CONF_API_KEY
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.aiohttp_client import async_create_clientsession
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
def normalize_name(name: str) -> str:
|
||||
"""Normalize name to conform to HA naming convention using underscores.
|
||||
@@ -27,12 +35,10 @@ def normalize_name(name: str) -> str:
|
||||
normalized = ''.join(c if c.isalnum() or c == '_' else '_' for c in name)
|
||||
normalized = '_'.join(filter(None, normalized.split('_'))).lower()
|
||||
if not normalized:
|
||||
import hashlib
|
||||
digest = hashlib.sha256(name.encode("utf-8", errors="replace")).hexdigest()[:8]
|
||||
normalized = f"instance_{digest}"
|
||||
return normalized
|
||||
|
||||
|
||||
def safe_log_data(
|
||||
data: dict[str, Any],
|
||||
sensitive_keys: tuple[str, ...] = (CONF_API_KEY,),
|
||||
@@ -40,11 +46,9 @@ def safe_log_data(
|
||||
"""Filter sensitive keys from data for safe logging."""
|
||||
return {k: "***" if k in sensitive_keys else v for k, v in data.items()}
|
||||
|
||||
|
||||
class _RestrictedIPError(ValueError):
|
||||
"""Raised when an IP address is in a restricted range."""
|
||||
|
||||
|
||||
def _check_ip_restricted(addr: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
|
||||
"""Check if an IP address is in a restricted range."""
|
||||
return (
|
||||
@@ -56,14 +60,136 @@ def _check_ip_restricted(addr: ipaddress.IPv4Address | ipaddress.IPv6Address) ->
|
||||
or addr.is_unspecified
|
||||
)
|
||||
|
||||
def _is_cloud_metadata_or_unsafe(
|
||||
addr: ipaddress.IPv4Address | ipaddress.IPv6Address,
|
||||
) -> bool:
|
||||
"""Block link-local and cloud instance-metadata addresses.
|
||||
|
||||
async def validate_endpoint(hass: HomeAssistant, endpoint: str, *, allow_local: bool = False) -> str:
|
||||
"""Validate API endpoint URL for security.
|
||||
These must be blocked even in allow_local_network mode:
|
||||
- IPv4 link-local (169.254.0.0/16) covers AWS/GCP/Azure IMDS 169.254.169.254.
|
||||
- IPv6 link-local (fe80::/10).
|
||||
- Multicast and unspecified addresses.
|
||||
Without this check, a self-hosted HA running on a cloud VM could be
|
||||
tricked into exfiltrating cloud credentials via IMDS.
|
||||
"""
|
||||
return addr.is_multicast or addr.is_unspecified or addr.is_link_local
|
||||
|
||||
class _PinnedResolver(AbstractResolver):
|
||||
"""aiohttp resolver that returns pre-validated IPs for a single hostname.
|
||||
|
||||
Why: prevents DNS-rebinding attacks. After validate_endpoint has
|
||||
confirmed the hostname resolves to a safe IP, we pin that IP in the
|
||||
resolver used by the aiohttp session. aiohttp then skips its own
|
||||
DNS lookup on each request and uses the pinned IP, closing the
|
||||
TOCTOU gap between validation and actual HTTP call.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
pinned: dict[str, list[tuple[str, int]]],
|
||||
fallback: AbstractResolver | None = None,
|
||||
) -> None:
|
||||
self._pinned = pinned
|
||||
self._fallback = fallback or DefaultResolver()
|
||||
|
||||
async def resolve(
|
||||
self,
|
||||
host: str,
|
||||
port: int = 0,
|
||||
family: int = socket.AF_INET,
|
||||
) -> list[dict[str, Any]]:
|
||||
entries = self._pinned.get(host.lower())
|
||||
if entries is None:
|
||||
return await self._fallback.resolve(host, port, family)
|
||||
return [
|
||||
{
|
||||
"hostname": host,
|
||||
"host": ip,
|
||||
"port": port or default_port,
|
||||
"family": _family_for(ip),
|
||||
"proto": 0,
|
||||
"flags": 0,
|
||||
}
|
||||
for ip, default_port in entries
|
||||
]
|
||||
|
||||
async def close(self) -> None:
|
||||
await self._fallback.close()
|
||||
|
||||
def _family_for(ip: str) -> int:
|
||||
"""Return AF_INET or AF_INET6 based on the IP literal."""
|
||||
try:
|
||||
return socket.AF_INET6 if ":" in ip else socket.AF_INET
|
||||
except Exception:
|
||||
return socket.AF_INET
|
||||
|
||||
async def resolve_hostname_ips(
|
||||
hass: HomeAssistant,
|
||||
hostname: str,
|
||||
) -> list[str]:
|
||||
"""Resolve hostname to all its IPs via the event-loop-safe executor.
|
||||
|
||||
Returns a list of IP strings (may contain both IPv4 and IPv6).
|
||||
Raises ValueError on resolution failure.
|
||||
"""
|
||||
try:
|
||||
addrinfos = await hass.async_add_executor_job(
|
||||
socket.getaddrinfo, hostname, None
|
||||
)
|
||||
except socket.gaierror as err:
|
||||
raise ValueError(f"Cannot resolve hostname: {hostname}") from err
|
||||
ips = []
|
||||
seen: set[str] = set()
|
||||
for _family, _type, _proto, _canonname, sockaddr in addrinfos:
|
||||
ip = sockaddr[0]
|
||||
if ip not in seen:
|
||||
seen.add(ip)
|
||||
ips.append(ip)
|
||||
if not ips:
|
||||
raise ValueError(f"No IPs for hostname: {hostname}")
|
||||
return ips
|
||||
|
||||
def create_pinned_session(
|
||||
hass: HomeAssistant,
|
||||
endpoint: str,
|
||||
resolved_ips: list[str],
|
||||
) -> aiohttp.ClientSession:
|
||||
"""Create an isolated aiohttp session with pinned DNS and no cookie jar.
|
||||
|
||||
Addresses two issues at once:
|
||||
- DNS rebinding: aiohttp will reuse the pinned IPs from validate_endpoint
|
||||
rather than re-resolving the hostname on each request.
|
||||
- Cookie pollution: DummyCookieJar prevents cookies from leaking between
|
||||
this integration and other HA components sharing the same domain.
|
||||
"""
|
||||
parsed = urlparse(endpoint)
|
||||
hostname = (parsed.hostname or "").lower()
|
||||
port = parsed.port or (443 if parsed.scheme == "https" else 80)
|
||||
pinned: dict[str, list[tuple[str, int]]] = {
|
||||
hostname: [(ip, port) for ip in resolved_ips]
|
||||
}
|
||||
connector = aiohttp.TCPConnector(resolver=_PinnedResolver(pinned))
|
||||
return async_create_clientsession(
|
||||
hass,
|
||||
cookie_jar=aiohttp.DummyCookieJar(),
|
||||
connector=connector,
|
||||
)
|
||||
|
||||
async def validate_endpoint(
|
||||
hass: HomeAssistant,
|
||||
endpoint: str,
|
||||
*,
|
||||
allow_local: bool = False,
|
||||
) -> tuple[str, list[str]]:
|
||||
"""Validate API endpoint URL for security and pin resolved IPs.
|
||||
|
||||
Ensures HTTPS-only and blocks private/reserved IP ranges (SSRF protection).
|
||||
When allow_local is True, permits private IPs and HTTP scheme for self-hosted proxies.
|
||||
Uses async DNS resolution to avoid blocking the event loop.
|
||||
Returns the validated endpoint stripped of trailing slashes.
|
||||
|
||||
Returns: (validated_endpoint_without_trailing_slash, resolved_ips).
|
||||
The resolved IPs are intended for pinning in aiohttp resolver via
|
||||
create_pinned_session(), closing the DNS-rebinding TOCTOU gap.
|
||||
|
||||
Raises:
|
||||
ValueError: If the endpoint fails validation.
|
||||
@@ -81,54 +207,47 @@ async def validate_endpoint(hass: HomeAssistant, endpoint: str, *, allow_local:
|
||||
if not hostname:
|
||||
raise ValueError("Invalid endpoint URL: no hostname")
|
||||
|
||||
if allow_local:
|
||||
# Even in local mode, block multicast and unspecified addresses
|
||||
resolved_ips: list[str] = []
|
||||
|
||||
# Collect and check all resolved IPs (or IP literal directly).
|
||||
def _collect(ips: list[str]) -> None:
|
||||
for ip in ips:
|
||||
if ip not in resolved_ips:
|
||||
resolved_ips.append(ip)
|
||||
|
||||
try:
|
||||
addr = ipaddress.ip_address(hostname)
|
||||
_is_ip_literal = True
|
||||
try:
|
||||
addr = ipaddress.ip_address(hostname)
|
||||
except ValueError:
|
||||
_is_ip_literal = False
|
||||
except ValueError:
|
||||
addr = None
|
||||
_is_ip_literal = False
|
||||
|
||||
if _is_ip_literal:
|
||||
if addr.is_multicast or addr.is_unspecified:
|
||||
raise ValueError("Multicast and unspecified addresses are not allowed")
|
||||
else:
|
||||
# Not an IP literal — resolve and check
|
||||
try:
|
||||
addrinfos = await hass.async_add_executor_job(
|
||||
socket.getaddrinfo, hostname, None
|
||||
)
|
||||
for family, _type, _proto, _canonname, sockaddr in addrinfos:
|
||||
resolved = ipaddress.ip_address(sockaddr[0])
|
||||
if resolved.is_multicast or resolved.is_unspecified:
|
||||
raise ValueError(
|
||||
"Hostname resolves to multicast/unspecified address"
|
||||
)
|
||||
except socket.gaierror as err:
|
||||
raise ValueError(f"Cannot resolve hostname: {hostname}") from err
|
||||
if _is_ip_literal:
|
||||
_collect([hostname])
|
||||
else:
|
||||
# Full SSRF protection — block private/reserved IPs
|
||||
try:
|
||||
addr = ipaddress.ip_address(hostname)
|
||||
if _check_ip_restricted(addr):
|
||||
raise _RestrictedIPError("Private/reserved IP addresses are not allowed")
|
||||
except _RestrictedIPError:
|
||||
raise
|
||||
except ValueError:
|
||||
# Not an IP literal — resolve hostname and check all resolved IPs
|
||||
# to prevent DNS rebinding attacks
|
||||
try:
|
||||
addrinfos = await hass.async_add_executor_job(
|
||||
socket.getaddrinfo, hostname, None
|
||||
)
|
||||
for family, _type, _proto, _canonname, sockaddr in addrinfos:
|
||||
ip_str = sockaddr[0]
|
||||
resolved_addr = ipaddress.ip_address(ip_str)
|
||||
if _check_ip_restricted(resolved_addr):
|
||||
raise ValueError(
|
||||
"Hostname resolves to a restricted IP range"
|
||||
)
|
||||
except socket.gaierror as err:
|
||||
raise ValueError(f"Cannot resolve hostname: {hostname}") from err
|
||||
addrinfos = await hass.async_add_executor_job(
|
||||
socket.getaddrinfo, hostname, None
|
||||
)
|
||||
except socket.gaierror as err:
|
||||
raise ValueError(f"Cannot resolve hostname: {hostname}") from err
|
||||
_collect([sockaddr[0] for (*_, sockaddr) in addrinfos])
|
||||
|
||||
return endpoint.rstrip("/")
|
||||
if not resolved_ips:
|
||||
raise ValueError(f"No IPs resolved for hostname: {hostname}")
|
||||
|
||||
# Validate each resolved IP against the selected policy.
|
||||
for ip_str in resolved_ips:
|
||||
ip_obj = ipaddress.ip_address(ip_str)
|
||||
if allow_local:
|
||||
if _is_cloud_metadata_or_unsafe(ip_obj):
|
||||
raise ValueError(
|
||||
"Link-local/metadata/multicast addresses are not allowed"
|
||||
)
|
||||
else:
|
||||
if _check_ip_restricted(ip_obj):
|
||||
raise _RestrictedIPError(
|
||||
"Private/reserved IP addresses are not allowed"
|
||||
)
|
||||
|
||||
return endpoint.rstrip("/"), resolved_ips
|
||||
|
||||
Reference in New Issue
Block a user