From 1e2ff81d07ec40e04aa5eab40d07099364a1820b Mon Sep 17 00:00:00 2001 From: SMKRV Date: Mon, 23 Mar 2026 12:18:58 +0300 Subject: [PATCH] feat: Add allow_local_network option for self-hosted LLM proxies Add per-instance boolean option to allow private IP endpoints and HTTP scheme for self-hosted LLM proxies (LiteLLM, Ollama, vLLM, etc.). - New CONF_ALLOW_LOCAL_NETWORK config option (default: false) - When enabled: allows RFC1918 private IPs and HTTP endpoints - When disabled: full SSRF protection preserved (HTTPS + public IPs only) - Multicast and unspecified addresses blocked regardless of setting - Warning logged when local network mode is active - Checkbox added to ConfigFlow and OptionsFlow UI - Translations for all 8 languages Closes #9 --- custom_components/ha_text_ai/__init__.py | 11 ++- custom_components/ha_text_ai/config_flow.py | 23 +++++- custom_components/ha_text_ai/const.py | 4 +- custom_components/ha_text_ai/manifest.json | 2 +- custom_components/ha_text_ai/strings.json | 9 ++- .../ha_text_ai/translations/de.json | 9 ++- .../ha_text_ai/translations/en.json | 9 ++- .../ha_text_ai/translations/es.json | 9 ++- .../ha_text_ai/translations/hi.json | 9 ++- .../ha_text_ai/translations/it.json | 9 ++- .../ha_text_ai/translations/ru.json | 9 ++- .../ha_text_ai/translations/sr.json | 9 ++- .../ha_text_ai/translations/zh.json | 9 ++- custom_components/ha_text_ai/utils.py | 81 +++++++++++++------ 14 files changed, 143 insertions(+), 59 deletions(-) diff --git a/custom_components/ha_text_ai/__init__.py b/custom_components/ha_text_ai/__init__.py index 6b9fccd..d1cda59 100644 --- a/custom_components/ha_text_ai/__init__.py +++ b/custom_components/ha_text_ai/__init__.py @@ -49,6 +49,8 @@ from .const import ( SERVICE_SET_SYSTEM_PROMPT, DEFAULT_MAX_HISTORY, CONF_MAX_HISTORY_SIZE, + CONF_ALLOW_LOCAL_NETWORK, + DEFAULT_ALLOW_LOCAL_NETWORK, ) _LOGGER = logging.getLogger(__name__) @@ -271,8 +273,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: model = config.get(CONF_MODEL, get_default_model(api_provider)) raw_endpoint = config.get(CONF_API_ENDPOINT, get_default_endpoint(api_provider)) + allow_local = config.get(CONF_ALLOW_LOCAL_NETWORK, DEFAULT_ALLOW_LOCAL_NETWORK) + if allow_local: + _LOGGER.warning( + "Local network mode enabled for endpoint %s — " + "SSRF protection disabled, API credentials may be sent without TLS", + raw_endpoint, + ) try: - endpoint = await validate_endpoint(hass, raw_endpoint) + endpoint = await validate_endpoint(hass, raw_endpoint, allow_local=allow_local) except ValueError as err: _LOGGER.error("Invalid API endpoint: %s", err) raise ConfigEntryNotReady(f"Invalid API endpoint: {err}") from err diff --git a/custom_components/ha_text_ai/config_flow.py b/custom_components/ha_text_ai/config_flow.py index 43cb4e6..c691d9d 100644 --- a/custom_components/ha_text_ai/config_flow.py +++ b/custom_components/ha_text_ai/config_flow.py @@ -54,6 +54,8 @@ from .const import ( MAX_CONTEXT_MESSAGES, MIN_HISTORY_SIZE, MAX_HISTORY_SIZE, + CONF_ALLOW_LOCAL_NETWORK, + DEFAULT_ALLOW_LOCAL_NETWORK, ) from homeassistant.util import dt as dt_util @@ -132,6 +134,10 @@ class HATextAIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): vol.Required(CONF_API_KEY): str, vol.Required(CONF_MODEL, default=defaults.get(CONF_MODEL, get_default_model(self._provider))): str, vol.Required(CONF_API_ENDPOINT, default=defaults.get(CONF_API_ENDPOINT, get_default_endpoint(self._provider))): str, + vol.Optional( + CONF_ALLOW_LOCAL_NETWORK, + default=defaults.get(CONF_ALLOW_LOCAL_NETWORK, DEFAULT_ALLOW_LOCAL_NETWORK), + ): bool, } schema_dict.update(_build_parameter_schema(defaults)) return vol.Schema(schema_dict) @@ -224,7 +230,10 @@ class HATextAIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return False try: - endpoint = await validate_endpoint(self.hass, user_input[CONF_API_ENDPOINT]) + allow_local = user_input.get(CONF_ALLOW_LOCAL_NETWORK, DEFAULT_ALLOW_LOCAL_NETWORK) + endpoint = await validate_endpoint( + self.hass, user_input[CONF_API_ENDPOINT], allow_local=allow_local + ) except ValueError as err: _LOGGER.error("Endpoint validation failed: %s", err) self._errors["base"] = "cannot_connect" @@ -280,6 +289,7 @@ class HATextAIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): CONF_API_TIMEOUT: user_input.get(CONF_API_TIMEOUT, DEFAULT_API_TIMEOUT), CONF_CONTEXT_MESSAGES: user_input.get(CONF_CONTEXT_MESSAGES, DEFAULT_CONTEXT_MESSAGES), CONF_MAX_HISTORY_SIZE: user_input.get(CONF_MAX_HISTORY_SIZE, DEFAULT_MAX_HISTORY), + CONF_ALLOW_LOCAL_NETWORK: user_input.get(CONF_ALLOW_LOCAL_NETWORK, DEFAULT_ALLOW_LOCAL_NETWORK), } _LOGGER.debug("Creating config entry with data: %s", safe_log_data(entry_data)) @@ -299,7 +309,7 @@ class HATextAIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): class OptionsFlowHandler(config_entries.OptionsFlow): """Handle options flow.""" - async def _async_validate_api(self, provider: str, api_key: str, endpoint: str) -> bool: + async def _async_validate_api(self, provider: str, api_key: str, endpoint: str, *, allow_local: bool = False) -> bool: """Validate API connection using provider registry.""" try: if not api_key: @@ -307,7 +317,7 @@ class OptionsFlowHandler(config_entries.OptionsFlow): return False try: - endpoint = await validate_endpoint(self.hass, endpoint) + endpoint = 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" @@ -412,7 +422,8 @@ class OptionsFlowHandler(config_entries.OptionsFlow): if not api_key: api_key = current_data.get(CONF_API_KEY, "") - if await self._async_validate_api(provider, api_key, endpoint): + allow_local = user_input.get(CONF_ALLOW_LOCAL_NETWORK, DEFAULT_ALLOW_LOCAL_NETWORK) + if await self._async_validate_api(provider, api_key, endpoint, allow_local=allow_local): final_data = { CONF_API_PROVIDER: provider, **user_input, @@ -471,6 +482,10 @@ class OptionsFlowHandler(config_entries.OptionsFlow): CONF_MODEL, default=data.get(CONF_MODEL, default_model), ): str, + vol.Optional( + CONF_ALLOW_LOCAL_NETWORK, + default=data.get(CONF_ALLOW_LOCAL_NETWORK, DEFAULT_ALLOW_LOCAL_NETWORK), + ): bool, } schema_dict.update(_build_parameter_schema(data)) return vol.Schema(schema_dict) diff --git a/custom_components/ha_text_ai/const.py b/custom_components/ha_text_ai/const.py index 2004305..04ed450 100644 --- a/custom_components/ha_text_ai/const.py +++ b/custom_components/ha_text_ai/const.py @@ -29,7 +29,7 @@ API_PROVIDERS: Final = [ API_PROVIDER_GEMINI ] -VERSION: Final = "2.4.0" +VERSION: Final = "2.4.1" # Default endpoints DEFAULT_OPENAI_ENDPOINT: Final = "https://api.openai.com/v1" @@ -49,6 +49,7 @@ CONF_MAX_HISTORY_SIZE: Final = "max_history_size" # Correct constant name CONF_CONTEXT_MESSAGES: Final = "context_messages" CONF_STRUCTURED_OUTPUT: Final = "structured_output" CONF_JSON_SCHEMA: Final = "json_schema" +CONF_ALLOW_LOCAL_NETWORK: Final = "allow_local_network" ABSOLUTE_MAX_HISTORY_SIZE: Final = 200 # Hard cap; UI allows max MAX_HISTORY_SIZE (100) MAX_ATTRIBUTE_SIZE = 4 * 1024 @@ -67,6 +68,7 @@ DEFAULT_NAME: Final = "HA Text AI" DEFAULT_NAME_PREFIX = "ha_text_ai" DEFAULT_INSTANCE_NAME: Final = "my_assistant" DEFAULT_CONTEXT_MESSAGES: Final = 5 +DEFAULT_ALLOW_LOCAL_NETWORK: Final = False MIN_CONTEXT_MESSAGES: Final = 1 MAX_CONTEXT_MESSAGES: Final = 20 MIN_HISTORY_SIZE: Final = 1 diff --git a/custom_components/ha_text_ai/manifest.json b/custom_components/ha_text_ai/manifest.json index c51048a..104ddcf 100644 --- a/custom_components/ha_text_ai/manifest.json +++ b/custom_components/ha_text_ai/manifest.json @@ -15,5 +15,5 @@ "google-genai>=1.16.0" ], "single_config_entry": false, - "version": "2.4.0" + "version": "2.4.1" } diff --git a/custom_components/ha_text_ai/strings.json b/custom_components/ha_text_ai/strings.json index a5e06ff..c01b3aa 100644 --- a/custom_components/ha_text_ai/strings.json +++ b/custom_components/ha_text_ai/strings.json @@ -14,7 +14,8 @@ "request_interval": "Minimum time between requests (0.1-60 seconds)", "api_timeout": "API request timeout in seconds (5-600)", "context_messages": "Number of context messages to retain (1-20)", - "max_history_size": "Maximum conversation history size (1-100)" + "max_history_size": "Maximum conversation history size (1-100)", + "allow_local_network": "Allow local network endpoints (for self-hosted proxies)" } }, "user": { @@ -31,7 +32,8 @@ "request_interval": "Minimum time between requests (0.1-60 seconds)", "api_timeout": "API request timeout in seconds (5-600)", "context_messages": "Number of context messages to retain (1-20)", - "max_history_size": "Maximum conversation history size (1-100)" + "max_history_size": "Maximum conversation history size (1-100)", + "allow_local_network": "Allow local network endpoints (for self-hosted proxies)" } } }, @@ -83,7 +85,8 @@ "request_interval": "Minimum request interval (0.1-60 seconds)", "api_timeout": "API request timeout in seconds (5-600)", "context_messages": "Number of previous messages to include in context (1-20)", - "max_history_size": "Maximum conversation history size (1-100)" + "max_history_size": "Maximum conversation history size (1-100)", + "allow_local_network": "Allow local network endpoints (for self-hosted proxies)" } } } diff --git a/custom_components/ha_text_ai/translations/de.json b/custom_components/ha_text_ai/translations/de.json index 96d6d44..3592914 100644 --- a/custom_components/ha_text_ai/translations/de.json +++ b/custom_components/ha_text_ai/translations/de.json @@ -14,7 +14,8 @@ "request_interval": "Minimale Zeit zwischen Anfragen (0,1-60 Sekunden)", "api_timeout": "API-Anfrage Timeout in Sekunden (5-600)", "context_messages": "Anzahl der zu behaltenden Kontextnachrichten (1-20)", - "max_history_size": "Maximale Größe des Gesprächsverlaufs (1-100)" + "max_history_size": "Maximale Größe des Gesprächsverlaufs (1-100)", + "allow_local_network": "Lokale Netzwerkendpunkte erlauben (für selbst gehostete Proxys)" } }, "user": { @@ -31,7 +32,8 @@ "request_interval": "Minimale Zeit zwischen Anfragen (0,1-60 Sekunden)", "api_timeout": "API-Anfrage Timeout in Sekunden (5-600)", "context_messages": "Anzahl der zu behaltenden Kontextnachrichten (1-20)", - "max_history_size": "Maximale Größe des Gesprächsverlaufs (1-100)" + "max_history_size": "Maximale Größe des Gesprächsverlaufs (1-100)", + "allow_local_network": "Lokale Netzwerkendpunkte erlauben (für selbst gehostete Proxys)" } } }, @@ -83,7 +85,8 @@ "request_interval": "Minimales Anfrageintervall (0,1-60 Sekunden)", "api_timeout": "API-Anfrage Timeout in Sekunden (5-600)", "context_messages": "Anzahl der vorherigen Nachrichten, die im Kontext enthalten sein sollen (1-20)", - "max_history_size": "Maximale Größe des Gesprächsverlaufs (1-100)" + "max_history_size": "Maximale Größe des Gesprächsverlaufs (1-100)", + "allow_local_network": "Lokale Netzwerkendpunkte erlauben (für selbst gehostete Proxys)" } } } diff --git a/custom_components/ha_text_ai/translations/en.json b/custom_components/ha_text_ai/translations/en.json index a5e06ff..c01b3aa 100644 --- a/custom_components/ha_text_ai/translations/en.json +++ b/custom_components/ha_text_ai/translations/en.json @@ -14,7 +14,8 @@ "request_interval": "Minimum time between requests (0.1-60 seconds)", "api_timeout": "API request timeout in seconds (5-600)", "context_messages": "Number of context messages to retain (1-20)", - "max_history_size": "Maximum conversation history size (1-100)" + "max_history_size": "Maximum conversation history size (1-100)", + "allow_local_network": "Allow local network endpoints (for self-hosted proxies)" } }, "user": { @@ -31,7 +32,8 @@ "request_interval": "Minimum time between requests (0.1-60 seconds)", "api_timeout": "API request timeout in seconds (5-600)", "context_messages": "Number of context messages to retain (1-20)", - "max_history_size": "Maximum conversation history size (1-100)" + "max_history_size": "Maximum conversation history size (1-100)", + "allow_local_network": "Allow local network endpoints (for self-hosted proxies)" } } }, @@ -83,7 +85,8 @@ "request_interval": "Minimum request interval (0.1-60 seconds)", "api_timeout": "API request timeout in seconds (5-600)", "context_messages": "Number of previous messages to include in context (1-20)", - "max_history_size": "Maximum conversation history size (1-100)" + "max_history_size": "Maximum conversation history size (1-100)", + "allow_local_network": "Allow local network endpoints (for self-hosted proxies)" } } } diff --git a/custom_components/ha_text_ai/translations/es.json b/custom_components/ha_text_ai/translations/es.json index 86d3323..18f6e5a 100644 --- a/custom_components/ha_text_ai/translations/es.json +++ b/custom_components/ha_text_ai/translations/es.json @@ -14,7 +14,8 @@ "request_interval": "Tiempo mínimo entre solicitudes (0.1-60 segundos)", "api_timeout": "Tiempo de espera de solicitud API en segundos (5-600)", "context_messages": "Número de mensajes de contexto a retener (1-20)", - "max_history_size": "Tamaño máximo del historial de conversación (1-100)" + "max_history_size": "Tamaño máximo del historial de conversación (1-100)", + "allow_local_network": "Permitir endpoints de red local (para proxies autoalojados)" } }, "user": { @@ -31,7 +32,8 @@ "request_interval": "Tiempo mínimo entre solicitudes (0.1-60 segundos)", "api_timeout": "Tiempo de espera de solicitud API en segundos (5-600)", "context_messages": "Número de mensajes de contexto a retener (1-20)", - "max_history_size": "Tamaño máximo del historial de conversación (1-100)" + "max_history_size": "Tamaño máximo del historial de conversación (1-100)", + "allow_local_network": "Permitir endpoints de red local (para proxies autoalojados)" } } }, @@ -83,7 +85,8 @@ "request_interval": "Intervalo mínimo de solicitud (0.1-60 segundos)", "api_timeout": "Tiempo de espera de solicitud API en segundos (5-600)", "context_messages": "Número de mensajes anteriores a incluir en el contexto (1-20)", - "max_history_size": "Tamaño máximo del historial de conversación (1-100)" + "max_history_size": "Tamaño máximo del historial de conversación (1-100)", + "allow_local_network": "Permitir endpoints de red local (para proxies autoalojados)" } } } diff --git a/custom_components/ha_text_ai/translations/hi.json b/custom_components/ha_text_ai/translations/hi.json index db6a4f2..d0062ce 100644 --- a/custom_components/ha_text_ai/translations/hi.json +++ b/custom_components/ha_text_ai/translations/hi.json @@ -14,7 +14,8 @@ "request_interval": "अनुरोधों के बीच न्यूनतम समय (0.1-60 सेकंड)", "api_timeout": "एपीआई अनुरोध टाइमआउट सेकंड में (5-600)", "context_messages": "रखने के लिए संदर्भ संदेशों की संख्या (1-20)", - "max_history_size": "अधिकतम बातचीत इतिहास आकार (1-100)" + "max_history_size": "अधिकतम बातचीत इतिहास आकार (1-100)", + "allow_local_network": "स्थानीय नेटवर्क एंडपॉइंट की अनुमति दें (सेल्फ-होस्टेड प्रॉक्सी के लिए)" } }, "user": { @@ -31,7 +32,8 @@ "request_interval": "अनुरोधों के बीच न्यूनतम समय (0.1-60 सेकंड)", "api_timeout": "एपीआई अनुरोध टाइमआउट सेकंड में (5-600)", "context_messages": "रखने के लिए संदर्भ संदेशों की संख्या (1-20)", - "max_history_size": "अधिकतम बातचीत इतिहास आकार (1-100)" + "max_history_size": "अधिकतम बातचीत इतिहास आकार (1-100)", + "allow_local_network": "स्थानीय नेटवर्क एंडपॉइंट की अनुमति दें (सेल्फ-होस्टेड प्रॉक्सी के लिए)" } } }, @@ -83,7 +85,8 @@ "request_interval": "न्यूनतम अनुरोध अंतराल (0.1-60 सेकंड)", "api_timeout": "एपीआई अनुरोध टाइमआउट सेकंड में (5-600)", "context_messages": "संदर्भ में शामिल करने के लिए पिछले संदेशों की संख्या (1-20)", - "max_history_size": "अधिकतम बातचीत इतिहास आकार (1-100)" + "max_history_size": "अधिकतम बातचीत इतिहास आकार (1-100)", + "allow_local_network": "स्थानीय नेटवर्क एंडपॉइंट की अनुमति दें (सेल्फ-होस्टेड प्रॉक्सी के लिए)" } } } diff --git a/custom_components/ha_text_ai/translations/it.json b/custom_components/ha_text_ai/translations/it.json index c4cbeca..f040020 100644 --- a/custom_components/ha_text_ai/translations/it.json +++ b/custom_components/ha_text_ai/translations/it.json @@ -14,7 +14,8 @@ "request_interval": "Tempo minimo tra le richieste (0.1-60 secondi)", "api_timeout": "Timeout della richiesta API in secondi (5-600)", "context_messages": "Numero di messaggi di contesto da mantenere (1-20)", - "max_history_size": "Dimensione massima della cronologia delle conversazioni (1-100)" + "max_history_size": "Dimensione massima della cronologia delle conversazioni (1-100)", + "allow_local_network": "Consenti endpoint di rete locale (per proxy self-hosted)" } }, "user": { @@ -31,7 +32,8 @@ "request_interval": "Tempo minimo tra le richieste (0.1-60 secondi)", "api_timeout": "Timeout della richiesta API in secondi (5-600)", "context_messages": "Numero di messaggi di contesto da mantenere (1-20)", - "max_history_size": "Dimensione massima della cronologia delle conversazioni (1-100)" + "max_history_size": "Dimensione massima della cronologia delle conversazioni (1-100)", + "allow_local_network": "Consenti endpoint di rete locale (per proxy self-hosted)" } } }, @@ -83,7 +85,8 @@ "request_interval": "Intervallo minimo di richiesta (0.1-60 secondi)", "api_timeout": "Timeout della richiesta API in secondi (5-600)", "context_messages": "Numero di messaggi precedenti da includere nel contesto (1-20)", - "max_history_size": "Dimensione massima della cronologia delle conversazioni (1-100)" + "max_history_size": "Dimensione massima della cronologia delle conversazioni (1-100)", + "allow_local_network": "Consenti endpoint di rete locale (per proxy self-hosted)" } } } diff --git a/custom_components/ha_text_ai/translations/ru.json b/custom_components/ha_text_ai/translations/ru.json index c57b18a..239d566 100644 --- a/custom_components/ha_text_ai/translations/ru.json +++ b/custom_components/ha_text_ai/translations/ru.json @@ -14,7 +14,8 @@ "request_interval": "Минимальный интервал между запросами (0.1-60 секунд)", "api_timeout": "Таймаут API-запроса в секундах (5-600)", "context_messages": "Количество сохраняемых контекстных сообщений (1-20)", - "max_history_size": "Максимальный размер истории разговора (1-100)" + "max_history_size": "Максимальный размер истории разговора (1-100)", + "allow_local_network": "Разрешить локальные сетевые адреса (для self-hosted прокси)" } }, "user": { @@ -31,7 +32,8 @@ "request_interval": "Минимальный интервал между запросами (0.1-60 секунд)", "api_timeout": "Таймаут API-запроса в секундах (5-600)", "context_messages": "Количество сохраняемых контекстных сообщений (1-20)", - "max_history_size": "Максимальный размер истории разговора (1-100)" + "max_history_size": "Максимальный размер истории разговора (1-100)", + "allow_local_network": "Разрешить локальные сетевые адреса (для self-hosted прокси)" } } }, @@ -83,7 +85,8 @@ "request_interval": "Минимальный интервал между запросами (0.1-60 секунд)", "api_timeout": "Таймаут API-запроса в секундах (5-600)", "context_messages": "Количество предыдущих сообщений для включения в контекст (1-20)", - "max_history_size": "Максимальный размер истории разговора (1-100)" + "max_history_size": "Максимальный размер истории разговора (1-100)", + "allow_local_network": "Разрешить локальные сетевые адреса (для self-hosted прокси)" } } } diff --git a/custom_components/ha_text_ai/translations/sr.json b/custom_components/ha_text_ai/translations/sr.json index aa7397d..a2fdecc 100644 --- a/custom_components/ha_text_ai/translations/sr.json +++ b/custom_components/ha_text_ai/translations/sr.json @@ -14,7 +14,8 @@ "request_interval": "Минимално време између захтева (0.1-60 секунди)", "api_timeout": "Временско ограничење API захтева у секундама (5-600)", "context_messages": "Број контекстуалних порука које треба задржати (1-20)", - "max_history_size": "Максимална величина историје разговора (1-100)" + "max_history_size": "Максимална величина историје разговора (1-100)", + "allow_local_network": "Дозволи локалне мрежне адресе (за self-hosted проксије)" } }, "user": { @@ -31,7 +32,8 @@ "request_interval": "Минимално време између захтева (0.1-60 секунди)", "api_timeout": "Временско ограничење API захтева у секундама (5-600)", "context_messages": "Број контекстуалних порука које треба задржати (1-20)", - "max_history_size": "Максимална величина историје разговора (1-100)" + "max_history_size": "Максимална величина историје разговора (1-100)", + "allow_local_network": "Дозволи локалне мрежне адресе (за self-hosted проксије)" } } }, @@ -83,7 +85,8 @@ "request_interval": "Минимално време између захтева (0.1-60 секунди)", "api_timeout": "Временско ограничење API захтева у секундама (5-600)", "context_messages": "Број претходних порука које треба укључити у контекст (1-20)", - "max_history_size": "Максимална величина историје разговора (1-100)" + "max_history_size": "Максимална величина историје разговора (1-100)", + "allow_local_network": "Дозволи локалне мрежне адресе (за self-hosted проксије)" } } } diff --git a/custom_components/ha_text_ai/translations/zh.json b/custom_components/ha_text_ai/translations/zh.json index b7d89bf..fd74cef 100644 --- a/custom_components/ha_text_ai/translations/zh.json +++ b/custom_components/ha_text_ai/translations/zh.json @@ -14,7 +14,8 @@ "request_interval": "请求之间的最小时间(0.1-60秒)", "api_timeout": "API请求超时时间(5-600秒)", "context_messages": "保留的上下文消息数量(1-20)", - "max_history_size": "最大对话历史大小(1-100)" + "max_history_size": "最大对话历史大小(1-100)", + "allow_local_network": "允许本地网络端点(用于自托管代理)" } }, "user": { @@ -31,7 +32,8 @@ "request_interval": "请求之间的最小时间(0.1-60秒)", "api_timeout": "API请求超时时间(5-600秒)", "context_messages": "保留的上下文消息数量(1-20)", - "max_history_size": "最大对话历史大小(1-100)" + "max_history_size": "最大对话历史大小(1-100)", + "allow_local_network": "允许本地网络端点(用于自托管代理)" } } }, @@ -83,7 +85,8 @@ "request_interval": "最小请求间隔(0.1-60秒)", "api_timeout": "API请求超时时间(5-600秒)", "context_messages": "要包含在上下文中的先前消息数量(1-20)", - "max_history_size": "最大对话历史大小(1-100)" + "max_history_size": "最大对话历史大小(1-100)", + "allow_local_network": "允许本地网络端点(用于自托管代理)" } } } diff --git a/custom_components/ha_text_ai/utils.py b/custom_components/ha_text_ai/utils.py index 1b81fac..5f511a2 100644 --- a/custom_components/ha_text_ai/utils.py +++ b/custom_components/ha_text_ai/utils.py @@ -48,10 +48,11 @@ def _check_ip_restricted(addr: ipaddress.IPv4Address | ipaddress.IPv6Address) -> ) -async def validate_endpoint(hass: HomeAssistant, endpoint: str) -> str: +async def validate_endpoint(hass: HomeAssistant, endpoint: str, *, allow_local: bool = False) -> str: """Validate API endpoint URL for security. 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. @@ -60,35 +61,65 @@ async def validate_endpoint(hass: HomeAssistant, endpoint: str) -> str: """ parsed = urlparse(endpoint) - if parsed.scheme not in ("https",): - raise ValueError("Only HTTPS endpoints are allowed") + if allow_local: + if parsed.scheme not in ("https", "http"): + raise ValueError("Only HTTPS and HTTP endpoints are allowed") + else: + if parsed.scheme not in ("https",): + raise ValueError("Only HTTPS endpoints are allowed") hostname = parsed.hostname if not hostname: raise ValueError("Invalid endpoint URL: no hostname") - # Block private/reserved IPs (direct IP or resolved hostname) - 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 + if allow_local: + # Even in local mode, block multicast and unspecified addresses + _is_ip_literal = True 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 + addr = ipaddress.ip_address(hostname) + except ValueError: + _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 + 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 return endpoint.rstrip("/")