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
+26 -21
View File
@@ -9,14 +9,13 @@ Config flow for HA text AI integration.
from __future__ import annotations
import logging
from typing import Any, Dict, Optional
from typing import Any
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.const import CONF_API_KEY, CONF_NAME
from homeassistant.core import callback
from homeassistant.config_entries import ConfigFlowResult
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers import selector
from .const import (
@@ -61,13 +60,17 @@ from .const import (
)
from homeassistant.util import dt as dt_util
from .utils import normalize_name, safe_log_data, validate_endpoint
from .utils import (
create_pinned_session,
normalize_name,
safe_log_data,
validate_endpoint,
)
from .providers import get_default_endpoint, get_default_model, build_auth_headers
_LOGGER = logging.getLogger(__name__)
def _build_parameter_schema(data: Dict[str, Any]) -> dict:
def _build_parameter_schema(data: dict[str, Any]) -> dict:
"""Build shared parameter schema fields used by both ConfigFlow and OptionsFlow."""
return {
vol.Optional(
@@ -100,7 +103,6 @@ def _build_parameter_schema(data: Dict[str, Any]) -> dict:
): bool,
}
class HATextAIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for HA text AI."""
@@ -112,7 +114,7 @@ class HATextAIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
self._data = {}
self._provider = None
async def async_step_user(self, user_input: Optional[Dict[str, Any]] = None) -> ConfigFlowResult:
async def async_step_user(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Handle the initial step."""
if user_input is None:
return self.async_show_form(
@@ -131,7 +133,7 @@ class HATextAIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
return await self.async_step_provider()
def _build_provider_schema(
self, data: Optional[Dict[str, Any]] = None
self, data: dict[str, Any] | None = None
) -> vol.Schema:
"""Build provider configuration schema with optional defaults from data."""
defaults = data or {}
@@ -150,7 +152,7 @@ class HATextAIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
schema_dict.update(_build_parameter_schema(defaults))
return vol.Schema(schema_dict)
async def async_step_provider(self, user_input: Optional[Dict[str, Any]] = None) -> ConfigFlowResult:
async def async_step_provider(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Handle provider configuration step."""
self._errors = {}
@@ -229,7 +231,7 @@ class HATextAIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
return normalized
async def _async_validate_api(self, user_input: Dict[str, Any]) -> bool:
async def _async_validate_api(self, user_input: dict[str, Any]) -> bool:
"""Validate API connection using provider registry."""
try:
if CONF_API_KEY not in user_input:
@@ -239,7 +241,7 @@ class HATextAIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
try:
allow_local = user_input.get(CONF_ALLOW_LOCAL_NETWORK, DEFAULT_ALLOW_LOCAL_NETWORK)
endpoint = await validate_endpoint(
endpoint, resolved_ips = await validate_endpoint(
self.hass, user_input[CONF_API_ENDPOINT], allow_local=allow_local
)
except ValueError as err:
@@ -253,7 +255,9 @@ class HATextAIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
return False
return True
session = async_get_clientsession(self.hass)
# Pinned session ensures the reachability check goes to the same
# IP that will later be used by api_client (no DNS rebinding).
session = create_pinned_session(self.hass, endpoint, resolved_ips)
headers = build_auth_headers(self._provider, user_input[CONF_API_KEY])
from .providers import get_provider_config
@@ -274,7 +278,7 @@ class HATextAIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
self._errors["base"] = "cannot_connect"
return False
async def _create_entry(self, user_input: Dict[str, Any]) -> ConfigFlowResult:
async def _create_entry(self, user_input: dict[str, Any]) -> ConfigFlowResult:
"""Create the config entry with unique_id deduplication."""
instance_name = user_input[CONF_NAME]
normalized_name = normalize_name(instance_name)
@@ -314,7 +318,6 @@ class HATextAIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Get the options flow for this handler."""
return OptionsFlowHandler()
class OptionsFlowHandler(config_entries.OptionsFlow):
"""Handle options flow."""
@@ -326,7 +329,9 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
return False
try:
endpoint = await validate_endpoint(self.hass, endpoint, allow_local=allow_local)
endpoint, resolved_ips = await validate_endpoint(
self.hass, endpoint, allow_local=allow_local
)
except ValueError as err:
_LOGGER.error("Endpoint validation failed: %s", err)
self._errors["base"] = "cannot_connect"
@@ -335,7 +340,7 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
if provider == API_PROVIDER_GEMINI:
return True
session = async_get_clientsession(self.hass)
session = create_pinned_session(self.hass, endpoint, resolved_ips)
headers = build_auth_headers(provider, api_key)
from .providers import get_provider_config
@@ -356,11 +361,11 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
self._errors["base"] = "cannot_connect"
return False
async def async_step_init(self, user_input: Optional[Dict[str, Any]] = None) -> ConfigFlowResult:
async def async_step_init(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Handle provider selection step."""
if not hasattr(self, "_errors"):
self._errors: dict[str, str] = {}
self._selected_provider: Optional[str] = None
self._selected_provider: str | None = None
current_data = {**self.config_entry.data, **self.config_entry.options}
current_provider = current_data.get(CONF_API_PROVIDER, API_PROVIDER_OPENAI)
@@ -386,7 +391,7 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
}
)
async def async_step_settings(self, user_input: Optional[Dict[str, Any]] = None) -> ConfigFlowResult:
async def async_step_settings(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Handle settings configuration step."""
self._errors = {}
current_data = {**self.config_entry.data, **self.config_entry.options}
@@ -477,8 +482,8 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
def _get_settings_schema(
self,
provider: str,
current_data: Dict[str, Any],
user_input: Optional[Dict[str, Any]],
current_data: dict[str, Any],
user_input: dict[str, Any] | None,
default_endpoint: str,
default_model: str,
) -> vol.Schema: