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,
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
+19 -4
View File
@@ -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)
+3 -1
View File
@@ -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
+1 -1
View File
@@ -15,5 +15,5 @@
"google-genai>=1.16.0"
],
"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)",
"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)"
}
}
}
@@ -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)"
}
}
}
@@ -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)"
}
}
}
@@ -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)"
}
}
}
@@ -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": "स्थानीय नेटवर्क एंडपॉइंट की अनुमति दें (सेल्फ-होस्टेड प्रॉक्सी के लिए)"
}
}
}
@@ -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)"
}
}
}
@@ -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 прокси)"
}
}
}
@@ -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 проксије)"
}
}
}
@@ -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": "允许本地网络端点(用于自托管代理)"
}
}
}
+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.
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("/")