Skip to content

Commit 1b9b94f

Browse files
committed
fix: harden LLM endpoint URL parsing
1 parent 81dfaff commit 1b9b94f

3 files changed

Lines changed: 55 additions & 4 deletions

File tree

app/config.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import logging
1313
import os
1414
import re
15+
import unicodedata
1516
from pathlib import Path
1617
from typing import Any
1718
from urllib.parse import urlsplit
@@ -550,7 +551,7 @@ def _normalize_base_url(value: Any) -> str:
550551
candidate = value.strip()
551552
if not candidate:
552553
return ""
553-
if any(char.isspace() or ord(char) < 32 for char in candidate):
554+
if any(char.isspace() or unicodedata.category(char) in {"Cc", "Cf"} for char in candidate):
554555
raise ValueError("LLM base URL must not contain whitespace or control characters")
555556

556557
try:
@@ -595,9 +596,13 @@ def _normalize_base_url(value: Any) -> str:
595596

596597
def _looks_like_alternate_numeric_ipv4(hostname: str) -> bool:
597598
parts = hostname.split(".")
598-
return bool(parts) and all(NUMERIC_HOST_PART_RE.fullmatch(part) for part in parts) and not (
599-
len(parts) == 4 and all(part.isdecimal() for part in parts)
600-
)
599+
if not parts or not all(NUMERIC_HOST_PART_RE.fullmatch(part) for part in parts):
600+
return False
601+
try:
602+
address = ipaddress.IPv4Address(hostname)
603+
except ValueError:
604+
return True
605+
return str(address) != hostname
601606

602607

603608
def _normalize_model(value: Any) -> str:

tests/test_config.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,12 +236,35 @@ def test_direct_setter_accepts_https_and_explicit_local_http_ranges(self, config
236236
"http://local\nhost/v1",
237237
"https://user:password@example.com/v1",
238238
"https://example.com:99999/v1",
239+
"https://0177.0.0.1/v1",
240+
"https://127.000.000.001/v1",
241+
"https://999.1.1.1/v1",
242+
"https://api.example.com/\x7f",
243+
"https://api.example.com/\u0080",
239244
],
240245
)
241246
def test_direct_setter_rejects_unsafe_or_malformed_urls(self, config, value):
242247
with pytest.raises(ValueError, match="LLM base URL"):
243248
config.llm_base_url = value
244249

250+
@pytest.mark.parametrize(
251+
"unsafe_url",
252+
[
253+
"https://0177.0.0.1/v1",
254+
"https://999.1.1.1/v1",
255+
],
256+
)
257+
def test_stored_noncanonical_numeric_https_url_is_cleared(self, config_dir, unsafe_url):
258+
config_dir.mkdir(parents=True, exist_ok=True)
259+
(config_dir / "config.json").write_text(
260+
json.dumps({"llm_base_url": unsafe_url}), encoding="utf-8"
261+
)
262+
263+
loaded = BlitztextConfig(config_dir=config_dir)
264+
265+
assert loaded.llm_base_url == ""
266+
assert loaded.has_unsafe_llm_base_url is True
267+
245268
def test_legacy_public_http_url_is_cleared_and_warned_without_echoing_url(self, config_dir, caplog):
246269
config_dir.mkdir(parents=True, exist_ok=True)
247270
unsafe_url = "http://api.example.com/v1"

tests/test_settings_dialog.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,29 @@ def test_save_settings_rejects_public_http_base_url_without_saving_or_accepting(
205205
assert not fake.config.config_file.exists()
206206

207207

208+
@pytest.mark.parametrize(
209+
"unsafe_url",
210+
[
211+
"https://0177.0.0.1/v1",
212+
"https://999.1.1.1/v1",
213+
"https://api.example.com/\x7f",
214+
"https://api.example.com/\u0080",
215+
],
216+
)
217+
def test_save_settings_rejects_invalid_https_url_without_saving_or_accepting(tmp_path, unsafe_url):
218+
config_dir = tmp_path / ".config" / "blitztext-linux"
219+
fake = _fake_save_self(config_dir, "standard")
220+
fake.edit_base_url = _Edit(unsafe_url)
221+
fake.accept = Mock()
222+
223+
with patch("app.blitztext_linux.QMessageBox") as message_box:
224+
SettingsDialog.save_settings(fake)
225+
226+
message_box.critical.assert_called_once()
227+
fake.accept.assert_not_called()
228+
assert not fake.config.config_file.exists()
229+
230+
208231
def test_save_settings_persists_and_applies_ui_language(tmp_path):
209232
config_dir = tmp_path / ".config" / "blitztext-linux"
210233
fake = _fake_save_self(config_dir, "standard", ui_language="en")

0 commit comments

Comments
 (0)