From 539960d953921adc70303bacfadbb8cfb823fd9d Mon Sep 17 00:00:00 2001 From: Ahmed Allam Date: Sat, 3 Oct 2026 22:06:08 +0000 Subject: [PATCH] fix(preflight): name a non-ASCII character in the API key instead of raising UnicodeEncodeError httpx encodes header values as ASCII, so a smart quote, non-breaking space or byte-order mark pasted into LLM_API_KEY (or LLM_EXTRA_HEADERS) surfaced as a bare UnicodeEncodeError from inside the client, wrapped in ModelConnectionError. The preflight now checks these settings first and fails with the setting name, the code point, its Unicode name and its position. Nothing is trimmed or rewritten and the key itself is never printed. --- strix/interface/scan_setup.py | 36 ++++++++++++ tests/test_preflight_header_values.py | 80 +++++++++++++++++++++++++++ 2 files changed, 116 insertions(+) create mode 100644 tests/test_preflight_header_values.py 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))