mirror of
https://github.com/usestrix/strix.git
synced 2026-10-04 02:33:47 +00:00
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:
parent
7280c40caa
commit
539960d953
2 changed files with 116 additions and 0 deletions
|
|
@ -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(
|
||||
|
|
|
|||
80
tests/test_preflight_header_values.py
Normal file
80
tests/test_preflight_header_values.py
Normal 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))
|
||||
Loading…
Add table
Reference in a new issue