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:
SMKRV
2026-04-17 01:58:16 +03:00
parent e8b9b911ef
commit 6e635f7e2f
11 changed files with 367 additions and 204 deletions
+172 -53
View File
@@ -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