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
This commit is contained in:
SMKRV
2026-03-23 12:18:58 +03:00
parent 47c731c9ee
commit 1e2ff81d07
14 changed files with 143 additions and 59 deletions
+10 -1
View File
@@ -49,6 +49,8 @@ from .const import (
SERVICE_SET_SYSTEM_PROMPT, SERVICE_SET_SYSTEM_PROMPT,
DEFAULT_MAX_HISTORY, DEFAULT_MAX_HISTORY,
CONF_MAX_HISTORY_SIZE, CONF_MAX_HISTORY_SIZE,
CONF_ALLOW_LOCAL_NETWORK,
DEFAULT_ALLOW_LOCAL_NETWORK,
) )
_LOGGER = logging.getLogger(__name__) _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)) model = config.get(CONF_MODEL, get_default_model(api_provider))
raw_endpoint = config.get(CONF_API_ENDPOINT, get_default_endpoint(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: try:
endpoint = await validate_endpoint(hass, raw_endpoint) endpoint = await validate_endpoint(hass, raw_endpoint, allow_local=allow_local)
except ValueError as err: except ValueError as err:
_LOGGER.error("Invalid API endpoint: %s", err) _LOGGER.error("Invalid API endpoint: %s", err)
raise ConfigEntryNotReady(f"Invalid API endpoint: {err}") from err raise ConfigEntryNotReady(f"Invalid API endpoint: {err}") from err
+19 -4
View File
@@ -54,6 +54,8 @@ from .const import (
MAX_CONTEXT_MESSAGES, MAX_CONTEXT_MESSAGES,
MIN_HISTORY_SIZE, MIN_HISTORY_SIZE,
MAX_HISTORY_SIZE, MAX_HISTORY_SIZE,
CONF_ALLOW_LOCAL_NETWORK,
DEFAULT_ALLOW_LOCAL_NETWORK,
) )
from homeassistant.util import dt as dt_util 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_API_KEY): str,
vol.Required(CONF_MODEL, default=defaults.get(CONF_MODEL, get_default_model(self._provider))): 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.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)) schema_dict.update(_build_parameter_schema(defaults))
return vol.Schema(schema_dict) return vol.Schema(schema_dict)
@@ -224,7 +230,10 @@ class HATextAIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
return False return False
try: 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: except ValueError as err:
_LOGGER.error("Endpoint validation failed: %s", err) _LOGGER.error("Endpoint validation failed: %s", err)
self._errors["base"] = "cannot_connect" 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_API_TIMEOUT: user_input.get(CONF_API_TIMEOUT, DEFAULT_API_TIMEOUT),
CONF_CONTEXT_MESSAGES: user_input.get(CONF_CONTEXT_MESSAGES, DEFAULT_CONTEXT_MESSAGES), 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_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)) _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): class OptionsFlowHandler(config_entries.OptionsFlow):
"""Handle options flow.""" """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.""" """Validate API connection using provider registry."""
try: try:
if not api_key: if not api_key:
@@ -307,7 +317,7 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
return False return False
try: try:
endpoint = await validate_endpoint(self.hass, endpoint) endpoint = await validate_endpoint(self.hass, endpoint, allow_local=allow_local)
except ValueError as err: except ValueError as err:
_LOGGER.error("Endpoint validation failed: %s", err) _LOGGER.error("Endpoint validation failed: %s", err)
self._errors["base"] = "cannot_connect" self._errors["base"] = "cannot_connect"
@@ -412,7 +422,8 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
if not api_key: if not api_key:
api_key = current_data.get(CONF_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 = { final_data = {
CONF_API_PROVIDER: provider, CONF_API_PROVIDER: provider,
**user_input, **user_input,
@@ -471,6 +482,10 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
CONF_MODEL, CONF_MODEL,
default=data.get(CONF_MODEL, default_model), default=data.get(CONF_MODEL, default_model),
): str, ): 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)) schema_dict.update(_build_parameter_schema(data))
return vol.Schema(schema_dict) return vol.Schema(schema_dict)
+3 -1
View File
@@ -29,7 +29,7 @@ API_PROVIDERS: Final = [
API_PROVIDER_GEMINI API_PROVIDER_GEMINI
] ]
VERSION: Final = "2.4.0" VERSION: Final = "2.4.1"
# Default endpoints # Default endpoints
DEFAULT_OPENAI_ENDPOINT: Final = "https://api.openai.com/v1" 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_CONTEXT_MESSAGES: Final = "context_messages"
CONF_STRUCTURED_OUTPUT: Final = "structured_output" CONF_STRUCTURED_OUTPUT: Final = "structured_output"
CONF_JSON_SCHEMA: Final = "json_schema" 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) ABSOLUTE_MAX_HISTORY_SIZE: Final = 200 # Hard cap; UI allows max MAX_HISTORY_SIZE (100)
MAX_ATTRIBUTE_SIZE = 4 * 1024 MAX_ATTRIBUTE_SIZE = 4 * 1024
@@ -67,6 +68,7 @@ DEFAULT_NAME: Final = "HA Text AI"
DEFAULT_NAME_PREFIX = "ha_text_ai" DEFAULT_NAME_PREFIX = "ha_text_ai"
DEFAULT_INSTANCE_NAME: Final = "my_assistant" DEFAULT_INSTANCE_NAME: Final = "my_assistant"
DEFAULT_CONTEXT_MESSAGES: Final = 5 DEFAULT_CONTEXT_MESSAGES: Final = 5
DEFAULT_ALLOW_LOCAL_NETWORK: Final = False
MIN_CONTEXT_MESSAGES: Final = 1 MIN_CONTEXT_MESSAGES: Final = 1
MAX_CONTEXT_MESSAGES: Final = 20 MAX_CONTEXT_MESSAGES: Final = 20
MIN_HISTORY_SIZE: Final = 1 MIN_HISTORY_SIZE: Final = 1
+1 -1
View File
@@ -15,5 +15,5 @@
"google-genai>=1.16.0" "google-genai>=1.16.0"
], ],
"single_config_entry": false, "single_config_entry": false,
"version": "2.4.0" "version": "2.4.1"
} }
+6 -3
View File
@@ -14,7 +14,8 @@
"request_interval": "Minimum time between requests (0.1-60 seconds)", "request_interval": "Minimum time between requests (0.1-60 seconds)",
"api_timeout": "API request timeout in seconds (5-600)", "api_timeout": "API request timeout in seconds (5-600)",
"context_messages": "Number of context messages to retain (1-20)", "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": { "user": {
@@ -31,7 +32,8 @@
"request_interval": "Minimum time between requests (0.1-60 seconds)", "request_interval": "Minimum time between requests (0.1-60 seconds)",
"api_timeout": "API request timeout in seconds (5-600)", "api_timeout": "API request timeout in seconds (5-600)",
"context_messages": "Number of context messages to retain (1-20)", "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)", "request_interval": "Minimum request interval (0.1-60 seconds)",
"api_timeout": "API request timeout in seconds (5-600)", "api_timeout": "API request timeout in seconds (5-600)",
"context_messages": "Number of previous messages to include in context (1-20)", "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)"
} }
} }
} }
@@ -14,7 +14,8 @@
"request_interval": "Minimale Zeit zwischen Anfragen (0,1-60 Sekunden)", "request_interval": "Minimale Zeit zwischen Anfragen (0,1-60 Sekunden)",
"api_timeout": "API-Anfrage Timeout in Sekunden (5-600)", "api_timeout": "API-Anfrage Timeout in Sekunden (5-600)",
"context_messages": "Anzahl der zu behaltenden Kontextnachrichten (1-20)", "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": { "user": {
@@ -31,7 +32,8 @@
"request_interval": "Minimale Zeit zwischen Anfragen (0,1-60 Sekunden)", "request_interval": "Minimale Zeit zwischen Anfragen (0,1-60 Sekunden)",
"api_timeout": "API-Anfrage Timeout in Sekunden (5-600)", "api_timeout": "API-Anfrage Timeout in Sekunden (5-600)",
"context_messages": "Anzahl der zu behaltenden Kontextnachrichten (1-20)", "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)", "request_interval": "Minimales Anfrageintervall (0,1-60 Sekunden)",
"api_timeout": "API-Anfrage Timeout in Sekunden (5-600)", "api_timeout": "API-Anfrage Timeout in Sekunden (5-600)",
"context_messages": "Anzahl der vorherigen Nachrichten, die im Kontext enthalten sein sollen (1-20)", "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)"
} }
} }
} }
@@ -14,7 +14,8 @@
"request_interval": "Minimum time between requests (0.1-60 seconds)", "request_interval": "Minimum time between requests (0.1-60 seconds)",
"api_timeout": "API request timeout in seconds (5-600)", "api_timeout": "API request timeout in seconds (5-600)",
"context_messages": "Number of context messages to retain (1-20)", "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": { "user": {
@@ -31,7 +32,8 @@
"request_interval": "Minimum time between requests (0.1-60 seconds)", "request_interval": "Minimum time between requests (0.1-60 seconds)",
"api_timeout": "API request timeout in seconds (5-600)", "api_timeout": "API request timeout in seconds (5-600)",
"context_messages": "Number of context messages to retain (1-20)", "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)", "request_interval": "Minimum request interval (0.1-60 seconds)",
"api_timeout": "API request timeout in seconds (5-600)", "api_timeout": "API request timeout in seconds (5-600)",
"context_messages": "Number of previous messages to include in context (1-20)", "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)"
} }
} }
} }
@@ -14,7 +14,8 @@
"request_interval": "Tiempo mínimo entre solicitudes (0.1-60 segundos)", "request_interval": "Tiempo mínimo entre solicitudes (0.1-60 segundos)",
"api_timeout": "Tiempo de espera de solicitud API en segundos (5-600)", "api_timeout": "Tiempo de espera de solicitud API en segundos (5-600)",
"context_messages": "Número de mensajes de contexto a retener (1-20)", "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": { "user": {
@@ -31,7 +32,8 @@
"request_interval": "Tiempo mínimo entre solicitudes (0.1-60 segundos)", "request_interval": "Tiempo mínimo entre solicitudes (0.1-60 segundos)",
"api_timeout": "Tiempo de espera de solicitud API en segundos (5-600)", "api_timeout": "Tiempo de espera de solicitud API en segundos (5-600)",
"context_messages": "Número de mensajes de contexto a retener (1-20)", "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)", "request_interval": "Intervalo mínimo de solicitud (0.1-60 segundos)",
"api_timeout": "Tiempo de espera de solicitud API en segundos (5-600)", "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)", "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)"
} }
} }
} }
@@ -14,7 +14,8 @@
"request_interval": "अनुरोधों के बीच न्यूनतम समय (0.1-60 सेकंड)", "request_interval": "अनुरोधों के बीच न्यूनतम समय (0.1-60 सेकंड)",
"api_timeout": "एपीआई अनुरोध टाइमआउट सेकंड में (5-600)", "api_timeout": "एपीआई अनुरोध टाइमआउट सेकंड में (5-600)",
"context_messages": "रखने के लिए संदर्भ संदेशों की संख्या (1-20)", "context_messages": "रखने के लिए संदर्भ संदेशों की संख्या (1-20)",
"max_history_size": "अधिकतम बातचीत इतिहास आकार (1-100)" "max_history_size": "अधिकतम बातचीत इतिहास आकार (1-100)",
"allow_local_network": "स्थानीय नेटवर्क एंडपॉइंट की अनुमति दें (सेल्फ-होस्टेड प्रॉक्सी के लिए)"
} }
}, },
"user": { "user": {
@@ -31,7 +32,8 @@
"request_interval": "अनुरोधों के बीच न्यूनतम समय (0.1-60 सेकंड)", "request_interval": "अनुरोधों के बीच न्यूनतम समय (0.1-60 सेकंड)",
"api_timeout": "एपीआई अनुरोध टाइमआउट सेकंड में (5-600)", "api_timeout": "एपीआई अनुरोध टाइमआउट सेकंड में (5-600)",
"context_messages": "रखने के लिए संदर्भ संदेशों की संख्या (1-20)", "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 सेकंड)", "request_interval": "न्यूनतम अनुरोध अंतराल (0.1-60 सेकंड)",
"api_timeout": "एपीआई अनुरोध टाइमआउट सेकंड में (5-600)", "api_timeout": "एपीआई अनुरोध टाइमआउट सेकंड में (5-600)",
"context_messages": "संदर्भ में शामिल करने के लिए पिछले संदेशों की संख्या (1-20)", "context_messages": "संदर्भ में शामिल करने के लिए पिछले संदेशों की संख्या (1-20)",
"max_history_size": "अधिकतम बातचीत इतिहास आकार (1-100)" "max_history_size": "अधिकतम बातचीत इतिहास आकार (1-100)",
"allow_local_network": "स्थानीय नेटवर्क एंडपॉइंट की अनुमति दें (सेल्फ-होस्टेड प्रॉक्सी के लिए)"
} }
} }
} }
@@ -14,7 +14,8 @@
"request_interval": "Tempo minimo tra le richieste (0.1-60 secondi)", "request_interval": "Tempo minimo tra le richieste (0.1-60 secondi)",
"api_timeout": "Timeout della richiesta API in secondi (5-600)", "api_timeout": "Timeout della richiesta API in secondi (5-600)",
"context_messages": "Numero di messaggi di contesto da mantenere (1-20)", "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": { "user": {
@@ -31,7 +32,8 @@
"request_interval": "Tempo minimo tra le richieste (0.1-60 secondi)", "request_interval": "Tempo minimo tra le richieste (0.1-60 secondi)",
"api_timeout": "Timeout della richiesta API in secondi (5-600)", "api_timeout": "Timeout della richiesta API in secondi (5-600)",
"context_messages": "Numero di messaggi di contesto da mantenere (1-20)", "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)", "request_interval": "Intervallo minimo di richiesta (0.1-60 secondi)",
"api_timeout": "Timeout della richiesta API in secondi (5-600)", "api_timeout": "Timeout della richiesta API in secondi (5-600)",
"context_messages": "Numero di messaggi precedenti da includere nel contesto (1-20)", "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)"
} }
} }
} }
@@ -14,7 +14,8 @@
"request_interval": "Минимальный интервал между запросами (0.1-60 секунд)", "request_interval": "Минимальный интервал между запросами (0.1-60 секунд)",
"api_timeout": "Таймаут API-запроса в секундах (5-600)", "api_timeout": "Таймаут API-запроса в секундах (5-600)",
"context_messages": "Количество сохраняемых контекстных сообщений (1-20)", "context_messages": "Количество сохраняемых контекстных сообщений (1-20)",
"max_history_size": "Максимальный размер истории разговора (1-100)" "max_history_size": "Максимальный размер истории разговора (1-100)",
"allow_local_network": "Разрешить локальные сетевые адреса (для self-hosted прокси)"
} }
}, },
"user": { "user": {
@@ -31,7 +32,8 @@
"request_interval": "Минимальный интервал между запросами (0.1-60 секунд)", "request_interval": "Минимальный интервал между запросами (0.1-60 секунд)",
"api_timeout": "Таймаут API-запроса в секундах (5-600)", "api_timeout": "Таймаут API-запроса в секундах (5-600)",
"context_messages": "Количество сохраняемых контекстных сообщений (1-20)", "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 секунд)", "request_interval": "Минимальный интервал между запросами (0.1-60 секунд)",
"api_timeout": "Таймаут API-запроса в секундах (5-600)", "api_timeout": "Таймаут API-запроса в секундах (5-600)",
"context_messages": "Количество предыдущих сообщений для включения в контекст (1-20)", "context_messages": "Количество предыдущих сообщений для включения в контекст (1-20)",
"max_history_size": "Максимальный размер истории разговора (1-100)" "max_history_size": "Максимальный размер истории разговора (1-100)",
"allow_local_network": "Разрешить локальные сетевые адреса (для self-hosted прокси)"
} }
} }
} }
@@ -14,7 +14,8 @@
"request_interval": "Минимално време између захтева (0.1-60 секунди)", "request_interval": "Минимално време између захтева (0.1-60 секунди)",
"api_timeout": "Временско ограничење API захтева у секундама (5-600)", "api_timeout": "Временско ограничење API захтева у секундама (5-600)",
"context_messages": "Број контекстуалних порука које треба задржати (1-20)", "context_messages": "Број контекстуалних порука које треба задржати (1-20)",
"max_history_size": "Максимална величина историје разговора (1-100)" "max_history_size": "Максимална величина историје разговора (1-100)",
"allow_local_network": "Дозволи локалне мрежне адресе (за self-hosted проксије)"
} }
}, },
"user": { "user": {
@@ -31,7 +32,8 @@
"request_interval": "Минимално време између захтева (0.1-60 секунди)", "request_interval": "Минимално време између захтева (0.1-60 секунди)",
"api_timeout": "Временско ограничење API захтева у секундама (5-600)", "api_timeout": "Временско ограничење API захтева у секундама (5-600)",
"context_messages": "Број контекстуалних порука које треба задржати (1-20)", "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 секунди)", "request_interval": "Минимално време између захтева (0.1-60 секунди)",
"api_timeout": "Временско ограничење API захтева у секундама (5-600)", "api_timeout": "Временско ограничење API захтева у секундама (5-600)",
"context_messages": "Број претходних порука које треба укључити у контекст (1-20)", "context_messages": "Број претходних порука које треба укључити у контекст (1-20)",
"max_history_size": "Максимална величина историје разговора (1-100)" "max_history_size": "Максимална величина историје разговора (1-100)",
"allow_local_network": "Дозволи локалне мрежне адресе (за self-hosted проксије)"
} }
} }
} }
@@ -14,7 +14,8 @@
"request_interval": "请求之间的最小时间(0.1-60秒)", "request_interval": "请求之间的最小时间(0.1-60秒)",
"api_timeout": "API请求超时时间(5-600秒)", "api_timeout": "API请求超时时间(5-600秒)",
"context_messages": "保留的上下文消息数量(1-20", "context_messages": "保留的上下文消息数量(1-20",
"max_history_size": "最大对话历史大小(1-100" "max_history_size": "最大对话历史大小(1-100",
"allow_local_network": "允许本地网络端点(用于自托管代理)"
} }
}, },
"user": { "user": {
@@ -31,7 +32,8 @@
"request_interval": "请求之间的最小时间(0.1-60秒)", "request_interval": "请求之间的最小时间(0.1-60秒)",
"api_timeout": "API请求超时时间(5-600秒)", "api_timeout": "API请求超时时间(5-600秒)",
"context_messages": "保留的上下文消息数量(1-20", "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秒)", "request_interval": "最小请求间隔(0.1-60秒)",
"api_timeout": "API请求超时时间(5-600秒)", "api_timeout": "API请求超时时间(5-600秒)",
"context_messages": "要包含在上下文中的先前消息数量(1-20)", "context_messages": "要包含在上下文中的先前消息数量(1-20)",
"max_history_size": "最大对话历史大小(1-100" "max_history_size": "最大对话历史大小(1-100",
"allow_local_network": "允许本地网络端点(用于自托管代理)"
} }
} }
} }
+56 -25
View File
@@ -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. """Validate API endpoint URL for security.
Ensures HTTPS-only and blocks private/reserved IP ranges (SSRF protection). 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. Uses async DNS resolution to avoid blocking the event loop.
Returns the validated endpoint stripped of trailing slashes. Returns the validated endpoint stripped of trailing slashes.
@@ -60,35 +61,65 @@ async def validate_endpoint(hass: HomeAssistant, endpoint: str) -> str:
""" """
parsed = urlparse(endpoint) parsed = urlparse(endpoint)
if parsed.scheme not in ("https",): if allow_local:
raise ValueError("Only HTTPS endpoints are allowed") 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 hostname = parsed.hostname
if not hostname: if not hostname:
raise ValueError("Invalid endpoint URL: no hostname") raise ValueError("Invalid endpoint URL: no hostname")
# Block private/reserved IPs (direct IP or resolved hostname) if allow_local:
try: # Even in local mode, block multicast and unspecified addresses
addr = ipaddress.ip_address(hostname) _is_ip_literal = True
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: try:
addrinfos = await hass.async_add_executor_job( addr = ipaddress.ip_address(hostname)
socket.getaddrinfo, hostname, None except ValueError:
) _is_ip_literal = False
for family, _type, _proto, _canonname, sockaddr in addrinfos:
ip_str = sockaddr[0] if _is_ip_literal:
resolved_addr = ipaddress.ip_address(ip_str) if addr.is_multicast or addr.is_unspecified:
if _check_ip_restricted(resolved_addr): raise ValueError("Multicast and unspecified addresses are not allowed")
raise ValueError( else:
"Hostname resolves to a restricted IP range" # Not an IP literal — resolve and check
) try:
except socket.gaierror as err: addrinfos = await hass.async_add_executor_job(
raise ValueError(f"Cannot resolve hostname: {hostname}") from err 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("/") return endpoint.rstrip("/")