diff --git a/custom_components/openai_compatible/config_flow.py b/custom_components/openai_compatible/config_flow.py index 5df3fcc..2c4da64 100644 --- a/custom_components/openai_compatible/config_flow.py +++ b/custom_components/openai_compatible/config_flow.py @@ -103,6 +103,7 @@ from .const import ( UNSUPPORTED_PRIORITY_SERVICE_TIERS_MODELS, UNSUPPORTED_WEB_SEARCH_MODELS, ) +from .url_util import InvalidBaseURL, normalize_base_url _LOGGER = logging.getLogger(__name__) @@ -119,6 +120,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None: Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. """ + data[CONF_BASE_URL] = normalize_base_url(data[CONF_BASE_URL]) client = openai.AsyncOpenAI( api_key=data[CONF_API_KEY], base_url=data[CONF_BASE_URL], @@ -145,6 +147,8 @@ class OpenAIConfigFlow(ConfigFlow, domain=DOMAIN): self._async_abort_entries_match(user_input) try: await validate_input(self.hass, user_input) + except InvalidBaseURL: + errors["base"] = "invalid_base_url" except openai.APIConnectionError: errors["base"] = "cannot_connect" except openai.AuthenticationError: diff --git a/custom_components/openai_compatible/strings.json b/custom_components/openai_compatible/strings.json index ab30ee9..f4bac57 100644 --- a/custom_components/openai_compatible/strings.json +++ b/custom_components/openai_compatible/strings.json @@ -7,6 +7,7 @@ "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "invalid_base_url": "That does not look like a valid http(s) URL.", "unknown": "[%key:common::config_flow::error::unknown%]" }, "step": { diff --git a/custom_components/openai_compatible/url_util.py b/custom_components/openai_compatible/url_util.py new file mode 100644 index 0000000..7ddd7c9 --- /dev/null +++ b/custom_components/openai_compatible/url_util.py @@ -0,0 +1,42 @@ +"""Base-URL normalization and safe logging. + +This module is intentionally separate from the vendored upstream files so that +re-syncing with home-assistant/core never conflicts with it. +""" + +from __future__ import annotations + +from urllib.parse import urlparse, urlunparse + +ALLOWED_SCHEMES = frozenset({"http", "https"}) + + +class InvalidBaseURL(ValueError): + """Raised when a base URL is unusable.""" + + +def normalize_base_url(url: str) -> str: + """Strip whitespace and trailing slashes, and validate the scheme.""" + cleaned = (url or "").strip() + if not cleaned: + raise InvalidBaseURL("base URL is empty") + + parsed = urlparse(cleaned) + if parsed.scheme not in ALLOWED_SCHEMES: + raise InvalidBaseURL(f"unsupported scheme: {parsed.scheme or '(none)'}") + if not parsed.netloc: + raise InvalidBaseURL("base URL has no host") + + return urlunparse(parsed._replace(path=parsed.path.rstrip("/"))) + + +def sanitize_url_for_logging(url: str) -> str: + """Return the URL with any embedded credentials replaced by ***.""" + parsed = urlparse(url) + if not parsed.username and not parsed.password: + return url + + host = parsed.hostname or "" + if parsed.port: + host = f"{host}:{parsed.port}" + return urlunparse(parsed._replace(netloc=f"***@{host}")) diff --git a/tests/test_url_util.py b/tests/test_url_util.py new file mode 100644 index 0000000..caba024 --- /dev/null +++ b/tests/test_url_util.py @@ -0,0 +1,39 @@ +"""URL hygiene: normalize input, never log credentials.""" + +import pytest + +from custom_components.openai_compatible.url_util import ( + InvalidBaseURL, + normalize_base_url, + sanitize_url_for_logging, +) + + +@pytest.mark.parametrize( + ("raw", "expected"), + [ + ("http://192.0.2.10:4000/v1", "http://192.0.2.10:4000/v1"), + ("http://192.0.2.10:4000/v1/", "http://192.0.2.10:4000/v1"), + (" https://api.openai.com/v1 ", "https://api.openai.com/v1"), + ("http://host:4000/v1///", "http://host:4000/v1"), + ], +) +def test_normalize(raw, expected): + assert normalize_base_url(raw) == expected + + +@pytest.mark.parametrize("raw", ["ftp://host/v1", "not-a-url", "", " "]) +def test_normalize_rejects(raw): + with pytest.raises(InvalidBaseURL): + normalize_base_url(raw) + + +def test_sanitize_strips_credentials(): + assert ( + sanitize_url_for_logging("https://user:secret@host:4000/v1") + == "https://***@host:4000/v1" + ) + + +def test_sanitize_leaves_clean_urls_alone(): + assert sanitize_url_for_logging("http://host:4000/v1") == "http://host:4000/v1"