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.
This commit is contained in:
Ahmed Allam 2026-10-03 22:06:08 +00:00 • committed by Ahmed Allam
parent 7280c40caa
commit 539960d953
2 changed files with 116 additions and 0 deletions

View file

@ -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(

View file

@ -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))