fix: reauth must preserve base_url; reject credentials embedded in base_url

Critical (review finding 1): async_step_reauth_confirm showed
STEP_USER_DATA_SCHEMA directly instead of prefilling it via
add_suggested_values_to_schema (unlike async_step_user, which does),
so the reauth form displayed DEFAULT_BASE_URL rather than the entry's
configured endpoint. A user rotating their API key would have their
integration silently repointed from e.g. LiteLLM back to
api.openai.com on save. Fixed by prefilling from
self._get_reauth_entry().data, same as the user step. strings.json's
reauth_confirm block now documents the base_url field too.
Regression test: test_reauth_preserves_base_url (asserts both the
shown form's suggested_value and the final stored base_url).

Important (review finding 2): base_url is user-suppliable and could
carry embedded user:pass@ credentials that later surface through
vendored exception logging (e.g. entity.py's "Error talking to
OpenAI: %s") this fork does not and should not edit. Rather than
touch vendored logging, reject credentials at the boundary:
normalize_base_url now raises the new CredentialsInBaseURL (a
subclass of InvalidBaseURL) when the URL has a username or password,
mapped to a distinct "credentials_in_url" form error telling the user
to use the API key field instead. Tests added in test_url_util.py and
test_config_flow.py.

Important (review finding 3): documented on sanitize_url_for_logging
that it is not a general-purpose credential scrubber -- it only masks
what urlparse recognizes as a netloc's user:pass@ authority component,
so a scheme-less/relative string with embedded credentials passes
through unmasked. Safe today because every caller normalizes first
(which now also rejects embedded credentials outright), but it is
exported for a later task's use and must not be assumed bulletproof
on an arbitrary unvalidated string.

Not fixed (reviewer's Minor 4, by direction): the default-config setup
call reaching api.openai.com with Authorization: Bearer not-required
when both base_url and api_key are left at defaults -- cosmetic, and
correctly surfaces as invalid_auth.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01739Seac5eMAfNwFeiwV5r4
This commit is contained in:
Adam SmithandClaude Sonnet 5 committed 2026-09-17 18:08:20 -04:00
1 parent 15889c905d
commit b96b46495a
5 files changed
+147 -7

No files matched your search

+79
View File
@@ -647,6 +647,24 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non
assert result2["errors"] == {"base": error}
async def test_form_credentials_in_base_url(hass: HomeAssistant) -> None:
"""A base_url with embedded credentials must be rejected, not accepted."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_BASE_URL: "https://user:secret@192.0.2.10:4000/v1",
"api_key": "bla",
},
)
assert result2["type"] is FlowResultType.FORM
assert result2["errors"] == {"base": "credentials_in_url"}
@pytest.mark.parametrize(
("current_options", "new_options", "expected_options"),
[
@@ -1597,6 +1615,67 @@ async def test_reauth(hass: HomeAssistant) -> None:
assert mock_config_entry.data[CONF_API_KEY] == "new_api_key"
async def test_reauth_preserves_base_url(hass: HomeAssistant) -> None:
"""Reauth must not silently reset a configured base_url to the default.
Regression test: async_step_reauth_confirm used to show
STEP_USER_DATA_SCHEMA without prefilling suggested values (unlike
async_step_user, which does), so the reauth form displayed
DEFAULT_BASE_URL instead of the entry's actual base_url -- and a user
re-authenticating after a key rotation would have their integration
silently repointed from their configured endpoint (e.g. LiteLLM) back to
api.openai.com.
"""
hass.config.components.add("openai_compatible")
configured_base_url = "http://192.0.2.10:4000/v1"
mock_config_entry = MockConfigEntry(
domain=DOMAIN,
data={CONF_API_KEY: "old-key", CONF_BASE_URL: configured_base_url},
state=config_entries.ConfigEntryState.LOADED,
version=2,
minor_version=7,
)
mock_config_entry.add_to_hass(hass)
result = await mock_config_entry.start_reauth_flow(hass)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reauth_confirm"
# The shown form must suggest the entry's own base_url, not the schema
# default -- this is the actual bug. Without the fix this assertion
# fails because no suggested_value is set at all.
schema_keys = result["data_schema"].schema
base_url_marker = next(k for k in schema_keys if k == CONF_BASE_URL)
assert base_url_marker.description == {"suggested_value": configured_base_url}
with (
patch(
"custom_components.openai_compatible.config_flow.openai.resources.models.AsyncModels.list",
new_callable=AsyncMock,
),
patch(
"custom_components.openai_compatible.async_setup_entry",
return_value=True,
),
):
# A real browser resubmits whatever is displayed in the form, so an
# unmodified base_url field is submitted back as-is alongside the
# new key -- this is what "the user didn't touch the field" means.
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_API_KEY: "new_api_key",
CONF_BASE_URL: configured_base_url,
},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reauth_successful"
assert mock_config_entry.data[CONF_API_KEY] == "new_api_key"
assert mock_config_entry.data[CONF_BASE_URL] == configured_base_url
@pytest.mark.parametrize(
("current_llm_apis", "suggested_llm_apis", "expected_options"),
[
+21
View File
@@ -3,6 +3,7 @@
import pytest
from custom_components.openai_compatible.url_util import (
CredentialsInBaseURL,
InvalidBaseURL,
normalize_base_url,
sanitize_url_for_logging,
@@ -28,6 +29,26 @@ def test_normalize_rejects(raw):
normalize_base_url(raw)
@pytest.mark.parametrize(
"raw",
[
"https://user:secret@host:4000/v1",
"https://user@host:4000/v1",
"https://:secret@host:4000/v1",
],
)
def test_normalize_rejects_embedded_credentials(raw):
"""Authentication belongs in the API key field, never in base_url."""
with pytest.raises(CredentialsInBaseURL):
normalize_base_url(raw)
def test_credentials_in_base_url_is_an_invalid_base_url():
"""CredentialsInBaseURL must still be catchable as InvalidBaseURL."""
with pytest.raises(InvalidBaseURL):
normalize_base_url("https://user:secret@host:4000/v1")
def test_sanitize_strips_credentials():
assert (
sanitize_url_for_logging("https://user:secret@host:4000/v1")