fix: Coordinator split, Gemini chat context, review findings fixes

- Extract HistoryManager (history.py) and MetricsManager (metrics.py) from coordinator
- Fix Gemini chat: use client.chats.create(history=...) instead of sequential send_message
- Fix float("inf") in metrics breaking json.dump persistence
- Fix _get_current_state checking wrong error key ("error" vs "error_message")
- Fix API key re-entry requirement when endpoint/provider changes in options flow
- Make CONF_API_KEY optional in options schema (stored key used as fallback)
- Add DNS rebinding protection via socket.getaddrinfo in validate_endpoint
- Remove mass assignment vulnerability in config_flow._create_entry
- Add description_placeholders to all error re-show paths
- Fix history migration overwriting existing JSON data
- Return list copy from get_limited_history to prevent reference leaks
- Add history_info to _get_safe_initial_state for data shape consistency
- Add defensive None check in _get_sanitized_last_response
- Move dt_util import to module level in config_flow
- Remove dead fallback branches in coordinator.last_response property
- Fix sensor min_latency display after float("inf") removal
- Fix history directory permissions from 0o777 to 0o755
- Deduplicate schema definitions via _build_provider_schema()
- Use centralized build_auth_headers from providers.py
This commit is contained in:
SMKRV
2026-03-12 01:50:17 +03:00
parent 63c5c7c51e
commit c54bfcff3b
7 changed files with 1053 additions and 1200 deletions
+20 -2
View File
@@ -7,6 +7,7 @@ Utility functions for HA Text AI integration.
@source: https://github.com/smkrv/ha-text-ai
"""
import ipaddress
import socket
from typing import Any
from urllib.parse import urlparse
@@ -47,7 +48,7 @@ def validate_endpoint(endpoint: str) -> str:
if not hostname:
raise ValueError("Invalid endpoint URL: no hostname")
# Block private/reserved IPs
# Block private/reserved IPs (direct IP or resolved hostname)
try:
addr = ipaddress.ip_address(hostname)
if addr.is_private or addr.is_reserved or addr.is_loopback or addr.is_link_local:
@@ -55,6 +56,23 @@ def validate_endpoint(endpoint: str) -> str:
except ValueError as e:
if "not allowed" in str(e):
raise
# Not an IP address — it's a hostname, which is fine
# Not an IP literal — resolve hostname and check all resolved IPs
# to prevent DNS rebinding attacks
try:
addrinfos = socket.getaddrinfo(hostname, None)
for family, _type, _proto, _canonname, sockaddr in addrinfos:
ip_str = sockaddr[0]
resolved_addr = ipaddress.ip_address(ip_str)
if (
resolved_addr.is_private
or resolved_addr.is_reserved
or resolved_addr.is_loopback
or resolved_addr.is_link_local
):
raise ValueError(
f"Hostname {hostname} resolves to private/reserved IP {ip_str}"
)
except socket.gaierror:
raise ValueError(f"Cannot resolve hostname: {hostname}")
return endpoint.rstrip("/")