diff --git a/strix/interface/scan_setup.py b/strix/interface/scan_setup.py index ae7caf2f..30c8db59 100644 --- a/strix/interface/scan_setup.py +++ b/strix/interface/scan_setup.py @@ -11,6 +11,7 @@ from __future__ import annotations import asyncio import logging +import unicodedata from datetime import UTC, datetime from typing import TYPE_CHECKING, Any @@ -58,6 +59,40 @@ class ModelConnectionError(RuntimeError): self.model_name = model_name +def _first_non_ascii(value: str) -> tuple[int, str] | None: + for position, char in enumerate(value): + if ord(char) > 0x7F: + return position, char + return None + + +def check_header_safe_credentials(settings: Settings) -> None: + """Reject credential settings that cannot travel as HTTP header values. + + httpx encodes header values as ASCII, so a smart quote, non-breaking space + or byte-order mark pasted into an API key otherwise surfaces as a bare + ``UnicodeEncodeError`` from inside the client. Name the setting and the + character instead; the value itself is neither printed nor rewritten. + """ + llm = settings.llm + candidates: list[tuple[str, str]] = [] + if llm.api_key: + candidates.append(("LLM_API_KEY", llm.api_key)) + for header, value in (llm.extra_headers or {}).items(): + candidates.append((f"LLM_EXTRA_HEADERS header name {header!r}", header)) + candidates.append((f"LLM_EXTRA_HEADERS value for {header!r}", value)) + for setting, value in candidates: + found = _first_non_ascii(value) + if found is None: + continue + position, char = found + raise ValueError( + f"{setting} contains a character that cannot be sent in an HTTP header: " + f"U+{ord(char):04X} ({unicodedata.name(char, 'unnamed character')}) " + f"at position {position + 1} of {len(value)}. Re-enter the value without it." + ) + + async def preflight_model_connection( model_name: str, *, @@ -70,6 +105,7 @@ async def preflight_model_connection( from strix.core.inputs import make_model_settings resolved_settings = load_settings() if settings is None else settings + check_header_safe_credentials(resolved_settings) configure_sdk_model_defaults(resolved_settings) model = StrixProvider().get_model(model_name) request_settings = make_model_settings( diff --git a/tests/test_preflight_header_values.py b/tests/test_preflight_header_values.py new file mode 100644 index 00000000..cdd8944e --- /dev/null +++ b/tests/test_preflight_header_values.py @@ -0,0 +1,80 @@ +"""Preflight names a non-ASCII character in a credential instead of raising UnicodeEncodeError.""" + +from __future__ import annotations + +import asyncio + +import pytest + +from strix.config import Settings +from strix.interface.scan_setup import check_header_safe_credentials, preflight_model_connection + + +_ENV_KEYS = ("LLM_API_KEY", "OPENAI_API_KEY", "LLM_EXTRA_HEADERS", "LLM_API_BASE", "STRIX_LLM") + + +@pytest.fixture(autouse=True) +def _clean_env(monkeypatch: pytest.MonkeyPatch) -> None: + for key in _ENV_KEYS: + monkeypatch.delenv(key, raising=False) + + +def _settings(monkeypatch: pytest.MonkeyPatch, **env: str) -> Settings: + for key, value in env.items(): + monkeypatch.setenv(key, value) + return Settings() + + +def test_ascii_credentials_pass(monkeypatch: pytest.MonkeyPatch) -> None: + settings = _settings( + monkeypatch, + LLM_API_KEY="sk-plain-ascii", + LLM_EXTRA_HEADERS='{"X-Team": "security"}', + ) + + check_header_safe_credentials(settings) + + +def test_smart_quote_in_api_key_is_named_without_leaking_the_key( + monkeypatch: pytest.MonkeyPatch, +) -> None: + settings = _settings(monkeypatch, LLM_API_KEY="sk-abc\u201d") + + with pytest.raises(ValueError, match="LLM_API_KEY") as excinfo: + check_header_safe_credentials(settings) + + message = str(excinfo.value) + assert "U+201D" in message + assert "RIGHT DOUBLE QUOTATION MARK" in message + assert "position 7 of 7" in message + assert "sk-abc" not in message + + +@pytest.mark.parametrize("char", ["\u00a0", "\ufeff", "\u200b"]) +def test_invisible_characters_are_reported(monkeypatch: pytest.MonkeyPatch, char: str) -> None: + settings = _settings(monkeypatch, LLM_API_KEY=f"{char}sk-abc") + + with pytest.raises(ValueError, match=f"U\\+{ord(char):04X}"): + check_header_safe_credentials(settings) + + +def test_extra_header_value_is_checked(monkeypatch: pytest.MonkeyPatch) -> None: + settings = _settings( + monkeypatch, + LLM_API_KEY="sk-plain", + LLM_EXTRA_HEADERS='{"X-Team": "s\u00e9curit\u00e9"}', + ) + + with pytest.raises(ValueError, match=r"LLM_EXTRA_HEADERS value for 'X-Team'.*U\+00E9"): + check_header_safe_credentials(settings) + + +def test_preflight_rejects_the_key_before_any_request(monkeypatch: pytest.MonkeyPatch) -> None: + settings = _settings( + monkeypatch, + LLM_API_KEY="sk-abc\u201d", + LLM_API_BASE="http://127.0.0.1:9/v1", + ) + + with pytest.raises(ValueError, match="LLM_API_KEY"): + asyncio.run(preflight_model_connection("openai/gpt-4o-mini", settings=settings))