From 9ee864ce5aaf4befd56b7de99145693c6a339c50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=3D=3DTIM=2E=C2=A9=2EB=20=20=3D=3D?= Date: Mon, 31 Aug 2026 02:19:22 +0200 Subject: [PATCH 1/3] fix: stop logging arbitrary key events --- app/hotkey_service.py | 35 +---------------------------------- tests/test_state_machine.py | 17 ++++++++++++++++- 2 files changed, 17 insertions(+), 35 deletions(-) diff --git a/app/hotkey_service.py b/app/hotkey_service.py index c787121..7b8eaaf 100644 --- a/app/hotkey_service.py +++ b/app/hotkey_service.py @@ -58,12 +58,6 @@ def hotkey_display_name(key_name: str) -> str: # Alle bekannten Meta- und Shift-Keycodes (als Namen; werden zur Laufzeit in Codes umgewandelt) _ALL_META_KEY_NAMES = ("KEY_LEFTMETA", "KEY_RIGHTMETA") _ALL_SHIFT_KEY_NAMES = ("KEY_LEFTSHIFT", "KEY_RIGHTSHIFT") -_DEBUG_MODIFIER_KEY_NAMES = ( - "KEY_LEFTALT", "KEY_RIGHTALT", - "KEY_LEFTCTRL", "KEY_RIGHTCTRL", - "KEY_LEFTMETA", "KEY_RIGHTMETA", - "KEY_LEFTSHIFT", "KEY_RIGHTSHIFT", -) def _modifier_match( @@ -96,11 +90,7 @@ def _modifier_match( result = False reason = "unexpected_shift" - logger.debug( - "modifier_match result=%s reason=%s pressed=%s required_meta=%s required_shift=%s all_meta=%s all_shift=%s", - result, reason, sorted(pressed), sorted(meta_codes), sorted(shift_codes), - sorted(all_meta_codes), sorted(all_shift_codes), - ) + logger.debug("modifier_match result=%s reason=%s", result, reason) return result @@ -216,10 +206,6 @@ def run(self) -> None: all_shift_codes = { getattr(ec, k) for k in _ALL_SHIFT_KEY_NAMES if hasattr(ec, k) } - debug_modifier_codes = { - getattr(ec, k): k for k in _DEBUG_MODIFIER_KEY_NAMES if hasattr(ec, k) - } - hotkeys = [] for workflow, tkey, mod_names in _HOTKEY_MAP: if workflow == WorkflowType.TRANSCRIPTION: @@ -303,16 +289,6 @@ def run(self) -> None: elif value == 0: pressed.discard(code) - key_name = _key_name(ec, code) - active_modifiers = sorted( - name for mod_code, name in debug_modifier_codes.items() - if mod_code in pressed - ) - logger.debug( - "evdev key event device=%s key=%s code=%s value=%s active_modifiers=%s", - getattr(dev, "path", ""), key_name, code, value, active_modifiers, - ) - # --- Hold-Modus: KEY_UP des aktiven Trigger-Keys stoppt --- if self._mode == "hold" and value == 0 and _hold_active is not None: for wf, tcode, _, _ in hotkeys: @@ -456,15 +432,6 @@ def _refresh_keyboard_devices(fd_to_dev: Dict[int, Any], transcription_key: str, return {dev.fd: dev for dev in devices} -def _key_name(ecodes, code: int) -> str: - name = ecodes.KEY.get(code) if hasattr(ecodes, "KEY") else None - if isinstance(name, list): - return "/".join(str(part) for part in name) - if name: - return str(name) - return str(code) - - def _group_names() -> Set[str]: try: import grp diff --git a/tests/test_state_machine.py b/tests/test_state_machine.py index 466da8d..c116606 100644 --- a/tests/test_state_machine.py +++ b/tests/test_state_machine.py @@ -259,7 +259,7 @@ def test_empty_transcript_emits_no_speech_not_error(self, tmp_path): def _make_fake_ecodes(): ec = types.SimpleNamespace(**_KEYCODES) - # _key_name() greift auf ecodes.KEY zu + # _FakeDevice.capabilities() uses ecodes.KEY. ec.KEY = {code: name for name, code in _KEYCODES.items() if name != "EV_KEY"} return ec @@ -383,6 +383,21 @@ def test_hold_at_threshold_emits_stop_not_discard(self): class TestLeftAltEvents: + def test_worker_debug_logs_exclude_raw_key_events_and_still_trigger_hotkeys(self, caplog): + """Debug diagnostics must not expose arbitrary key events.""" + caplog.set_level(logging.DEBUG, logger="blitztext.hotkey") + + triggered = _run_worker_with_events([ + (_KEYCODES["KEY_E"], 1), + (_KEYCODES["KEY_LEFTALT"], 1), + ]) + + log_messages = [record.getMessage() for record in caplog.records] + assert triggered == [WorkflowType.TRANSCRIPTION] + assert all("KEY_E" not in message for message in log_messages) + assert all("code=18" not in message for message in log_messages) + assert all("evdev key event" not in message for message in log_messages) + def test_leftalt_keydown_triggers_toggle(self): """value=1 (key-down) loest die Transkription aus.""" triggered = _run_worker_with_events([(_KEYCODES["KEY_LEFTALT"], 1)]) From 81dfaffc260b9ebe9efd7fb69760467491895a2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=3D=3DTIM=2E=C2=A9=2EB=20=20=3D=3D?= Date: Mon, 31 Aug 2026 11:24:18 +0200 Subject: [PATCH 2/3] fix: require TLS for public LLM endpoints --- SECURITY.md | 2 +- SUPPORT.md | 2 +- app/blitztext_linux.py | 13 +++++- app/config.py | 77 ++++++++++++++++++++++++++++++++--- app/i18n.py | 6 ++- docs/privacy.md | 6 +-- tests/test_config.py | 59 +++++++++++++++++++++++++++ tests/test_settings_dialog.py | 53 ++++++++++++++++++++++++ 8 files changed, 204 insertions(+), 14 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index 93c3225..b63644f 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -27,7 +27,7 @@ Include: ## Security Notes -- The app sends audio and text directly to OpenAI when you use the remote workflows. +- Normal transcription is performed locally. Only transcribed text is optionally sent to OpenAI, OpenRouter, or a configured custom LLM endpoint for rewrite workflows. - Your OpenAI API key is read from the environment. Put it in `~/.config/blitztext-linux/secrets.env` (chmod `600`) or export the configured environment variable before launch. - Temporary audio files may exist briefly during processing. - Auto-paste uses `ydotool` to inject `Ctrl+V` into the focused application. diff --git a/SUPPORT.md b/SUPPORT.md index 3c5075f..7721ea2 100644 --- a/SUPPORT.md +++ b/SUPPORT.md @@ -5,7 +5,7 @@ BlitztextLinux is an experimental Linux desktop app. There is no service-level a ## Before Asking For Help - Make sure you can install the app with `bash scripts/install.sh`. -- Confirm that your OpenAI API key is entered in the app settings if you use online workflows. +- Set your API key in the environment variable configured in the app settings before using online workflows. Settings stores only that variable's name, not the API key itself. - Verify that `bash scripts/verify.sh` succeeds. - If you expect auto-paste, make sure `ydotool.service` is running and your session has access to the `input` group. - Read [docs/privacy.md](docs/privacy.md) before testing with sensitive content. diff --git a/app/blitztext_linux.py b/app/blitztext_linux.py index 7f74e38..4cca559 100644 --- a/app/blitztext_linux.py +++ b/app/blitztext_linux.py @@ -275,6 +275,17 @@ def init_ui(self) -> None: self.edit_base_url.setText(self.config.llm_base_url) self.edit_base_url.setPlaceholderText("https://openrouter.ai/api/v1") self.edit_base_url.setEnabled(self.config.llm_provider != "openai") + base_url_layout = QVBoxLayout() + base_url_layout.addWidget(self.edit_base_url) + if self.config.has_unsafe_llm_base_url: + self.lbl_unsafe_llm_base_url_notice = QLabel( + t("settings.base_url.unsafe_legacy_notice") + ) + self.lbl_unsafe_llm_base_url_notice.setWordWrap(True) + self.lbl_unsafe_llm_base_url_notice.setStyleSheet("color: #b26a00; font-size: 10px;") + base_url_layout.addWidget(self.lbl_unsafe_llm_base_url_notice) + else: + self.lbl_unsafe_llm_base_url_notice = None self.edit_llm_model = QLineEdit() self.edit_llm_model.setText(self.config.llm_model) @@ -347,7 +358,7 @@ def init_ui(self) -> None: form_llm.addRow(t("settings.llm_provider.label"), self.combo_llm_provider) form_llm.addRow(create_help_label(t("settings.llm_provider.help"))) - form_llm.addRow(t("settings.base_url.label"), self.edit_base_url) + form_llm.addRow(t("settings.base_url.label"), base_url_layout) form_llm.addRow(create_help_label(t("settings.base_url.help"))) form_llm.addRow(t("settings.llm_model.label"), self.edit_llm_model) form_llm.addRow(create_help_label(t("settings.llm_model.help"))) diff --git a/app/config.py b/app/config.py index f9f52d5..62e3c7a 100644 --- a/app/config.py +++ b/app/config.py @@ -7,12 +7,14 @@ from __future__ import annotations import copy +import ipaddress import json import logging import os import re from pathlib import Path from typing import Any +from urllib.parse import urlsplit from app.writing_presets import ( DEFAULT_PRESET_KEY, @@ -69,7 +71,12 @@ VALID_TTS_PROVIDERS = {"piper", "openai"} VALID_OPENAI_TTS_VOICES = {"alloy", "ash", "ballad", "coral", "echo", "fable", "nova", "onyx", "sage", "shimmer", "verse", "marin", "cedar"} VALID_UI_LANGUAGES = set(I18N_LANGUAGES) -BASE_URL_RE = re.compile(r"^https?://", re.IGNORECASE) +PRIVATE_IPV4_NETWORKS = ( + ipaddress.ip_network("10.0.0.0/8"), + ipaddress.ip_network("172.16.0.0/12"), + ipaddress.ip_network("192.168.0.0/16"), +) +NUMERIC_HOST_PART_RE = re.compile(r"(?:0x[0-9a-f]+|0[0-7]*|[0-9]+)", re.IGNORECASE) VALID_HOTKEY_KEYS = { "KEY_LEFTALT", "KEY_RIGHTALT", "KEY_RIGHTCTRL", "KEY_LEFTCTRL", "KEY_F13", "KEY_F14", "KEY_F15", "KEY_F16", @@ -93,6 +100,7 @@ def __init__(self, config_dir: Path | None = None) -> None: self.config_file = self.config_dir / "config.json" self._legacy_openai_api_key_present = False self._legacy_openai_api_key_value = "" + self._has_unsafe_llm_base_url = False self._data = self._load() self._validate_and_sanitize() @@ -144,6 +152,7 @@ def save(self) -> None: self._data = payload self._legacy_openai_api_key_present = False self._legacy_openai_api_key_value = "" + self._has_unsafe_llm_base_url = False except OSError as exc: raise ConfigError(f"Config konnte nicht gespeichert werden: {exc}") from exc @@ -170,6 +179,10 @@ def resolve_openai_api_key(self) -> str: def has_legacy_openai_api_key(self) -> bool: return self._legacy_openai_api_key_present + @property + def has_unsafe_llm_base_url(self) -> bool: + return self._has_unsafe_llm_base_url + @property def llm_provider(self) -> str: value = self._data.get("llm_provider", DEFAULTS["llm_provider"]) @@ -183,7 +196,7 @@ def llm_provider(self, value: str) -> None: @property def llm_base_url(self) -> str: - return _normalize_base_url(self._data.get("llm_base_url", "")) + return self._data.get("llm_base_url", "") @llm_base_url.setter def llm_base_url(self, value: str) -> None: @@ -489,7 +502,12 @@ def _validate_and_sanitize(self) -> None: if self._data.get("llm_provider") not in VALID_LLM_PROVIDERS: self._data["llm_provider"] = DEFAULTS["llm_provider"] - self._data["llm_base_url"] = _normalize_base_url(self._data.get("llm_base_url", "")) + try: + self._data["llm_base_url"] = _normalize_base_url(self._data.get("llm_base_url", "")) + except ValueError: + self._data["llm_base_url"] = "" + self._has_unsafe_llm_base_url = True + logger.warning("Removed unsafe LLM base URL from configuration") self._data["llm_model"] = _normalize_model(self._data.get("llm_model", DEFAULTS["llm_model"])) if "workflows" not in self._data or not isinstance(self._data["workflows"], dict): @@ -528,11 +546,58 @@ def _normalize_env_var_name(value: Any) -> str: def _normalize_base_url(value: Any) -> str: if not isinstance(value, str): - return "" + raise ValueError("LLM base URL must be a string") candidate = value.strip() - if not candidate or not BASE_URL_RE.match(candidate): + if not candidate: return "" - return candidate + if any(char.isspace() or ord(char) < 32 for char in candidate): + raise ValueError("LLM base URL must not contain whitespace or control characters") + + try: + parsed = urlsplit(candidate) + hostname = parsed.hostname + port = parsed.port + except ValueError as exc: + raise ValueError("LLM base URL must contain a valid host and port") from exc + + if parsed.scheme.lower() not in {"http", "https"}: + raise ValueError("LLM base URL must use http or https") + if not hostname or parsed.username is not None or parsed.password is not None: + raise ValueError("LLM base URL must not contain credentials and requires a host") + if "%" in hostname: + raise ValueError("LLM base URL must not use an encoded host") + if port is not None and not 0 <= port <= 65535: + raise ValueError("LLM base URL must contain a valid port") + + if _looks_like_alternate_numeric_ipv4(hostname): + raise ValueError("LLM base URL must use a canonical IP address") + if parsed.scheme.lower() == "https": + return candidate + + if hostname.lower() == "localhost": + return candidate + + try: + address = ipaddress.ip_address(hostname) + except ValueError as exc: + raise ValueError("LLM base URL requires HTTPS for non-local endpoints") from exc + + if str(address) != hostname: + raise ValueError("LLM base URL must use a canonical IP address") + if address.version == 6 and str(address) == "::1": + return candidate + if address.version == 4 and ( + str(address) == "127.0.0.1" or any(address in network for network in PRIVATE_IPV4_NETWORKS) + ): + return candidate + raise ValueError("LLM base URL requires HTTPS for non-local endpoints") + + +def _looks_like_alternate_numeric_ipv4(hostname: str) -> bool: + parts = hostname.split(".") + return bool(parts) and all(NUMERIC_HOST_PART_RE.fullmatch(part) for part in parts) and not ( + len(parts) == 4 and all(part.isdecimal() for part in parts) + ) def _normalize_model(value: Any) -> str: diff --git a/app/i18n.py b/app/i18n.py index 833b2fd..307236b 100644 --- a/app/i18n.py +++ b/app/i18n.py @@ -43,7 +43,8 @@ "settings.llm_provider.custom_endpoint": "Eigener Endpunkt", "settings.llm_provider.help": "OpenAI = Standard. OpenRouter und 'Eigener Endpunkt' nutzen das OpenAI-kompatible API über eine eigene Basis-URL und ein eigenes Modell.", "settings.base_url.label": "Basis-URL (base_url):", - "settings.base_url.help": "Leer = OpenAI-Standard. Für OpenRouter: https://openrouter.ai/api/v1. Muss mit http:// oder https:// beginnen.", + "settings.base_url.help": "Leer = OpenAI-Standard. Öffentliche Endpunkte müssen https:// verwenden; http:// ist nur für localhost, 127.0.0.1, ::1 und private IPv4-Netze erlaubt.", + "settings.base_url.unsafe_legacy_notice": "Eine unsichere HTTP-Basis-URL aus der Konfiguration wurde entfernt. Verwende für öffentliche Endpunkte HTTPS.", "settings.llm_model.label": "LLM-Modell:", "settings.llm_model.help": "Modellname beim Anbieter, z. B. 'gpt-4o-mini' (OpenAI) oder 'openai/gpt-4o' (OpenRouter).", "settings.tone.label": "Ziel-Tonfall:", @@ -241,7 +242,8 @@ "settings.llm_provider.custom_endpoint": "Custom endpoint", "settings.llm_provider.help": "OpenAI = default. OpenRouter and 'Custom endpoint' use the OpenAI-compatible API with a custom base URL and custom model.", "settings.base_url.label": "Base URL (base_url):", - "settings.base_url.help": "Empty = OpenAI default. For OpenRouter: https://openrouter.ai/api/v1. Must start with http:// or https://.", + "settings.base_url.help": "Empty = OpenAI default. Public endpoints must use https://; http:// is only allowed for localhost, 127.0.0.1, ::1, and private IPv4 networks.", + "settings.base_url.unsafe_legacy_notice": "An unsafe HTTP base URL from the configuration was removed. Use HTTPS for public endpoints.", "settings.llm_model.label": "LLM model:", "settings.llm_model.help": "Model name at the provider, e.g. 'gpt-4o-mini' (OpenAI) or 'openai/gpt-4o' (OpenRouter).", "settings.tone.label": "Target tone:", diff --git a/docs/privacy.md b/docs/privacy.md index 85e9538..daf27b5 100644 --- a/docs/privacy.md +++ b/docs/privacy.md @@ -2,18 +2,18 @@ ## BlitztextLinux -BlitztextLinux stores the OpenAI API key in: +BlitztextLinux stores only the configured API-key environment-variable name in: ```text ~/.config/blitztext-linux/config.json ``` -That file is written with restrictive permissions (`0600`) so only the current user can read it. +That file is written with restrictive permissions (`0600`) so only the current user can read it. The API key itself is read from the configured environment variable or from `~/.config/blitztext-linux/secrets.env`, which must use permissions `0600`. ## Data flow - Local transcription workflows stay on the machine. -- LLM workflows send the transcribed text to OpenAI for rewriting. +- LLM workflows optionally send the transcribed text to OpenAI, OpenRouter, or a configured custom endpoint for rewriting. - Temporary audio files are created during processing and are removed when the workflow finishes or is cancelled. - Workflow output may be placed on the clipboard so you can paste it into another app. diff --git a/tests/test_config.py b/tests/test_config.py index af9ba65..c08baa5 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -200,6 +200,65 @@ def test_save_never_writes_api_key_with_provider_fields(self, config_dir): assert saved["llm_provider"] == "openrouter" +class TestLLMBaseUrlSecurity: + @pytest.mark.parametrize( + ("value", "expected"), + [ + ("https://api.example.com/v1", "https://api.example.com/v1"), + ("https://8.8.8.8/v1", "https://8.8.8.8/v1"), + ("https://[2001:4860:4860::8888]/v1", "https://[2001:4860:4860::8888]/v1"), + ("http://localhost:11434/v1", "http://localhost:11434/v1"), + ("http://127.0.0.1:11434/v1", "http://127.0.0.1:11434/v1"), + ("http://[::1]:11434/v1", "http://[::1]:11434/v1"), + ("http://10.0.0.1/v1", "http://10.0.0.1/v1"), + ("http://172.16.0.1/v1", "http://172.16.0.1/v1"), + ("http://192.168.1.1/v1", "http://192.168.1.1/v1"), + ], + ) + def test_direct_setter_accepts_https_and_explicit_local_http_ranges(self, config, value, expected): + config.llm_base_url = value + assert config.llm_base_url == expected + + @pytest.mark.parametrize( + "value", + [ + "http://api.example.com/v1", + "http://8.8.8.8/v1", + "http://172.32.0.1/v1", + "http://127.0.0.2/v1", + "http://169.254.1.1/v1", + "http://[::ffff:127.0.0.1]/v1", + "http://2130706433/v1", + "http://0x7f000001/v1", + "http://0177.0.0.1/v1", + "http://%31%32%37.0.0.1/v1", + "http:///v1", + "http://local\nhost/v1", + "https://user:password@example.com/v1", + "https://example.com:99999/v1", + ], + ) + def test_direct_setter_rejects_unsafe_or_malformed_urls(self, config, value): + with pytest.raises(ValueError, match="LLM base URL"): + config.llm_base_url = value + + def test_legacy_public_http_url_is_cleared_and_warned_without_echoing_url(self, config_dir, caplog): + config_dir.mkdir(parents=True, exist_ok=True) + unsafe_url = "http://api.example.com/v1" + (config_dir / "config.json").write_text( + json.dumps({"llm_base_url": unsafe_url}), encoding="utf-8" + ) + + with caplog.at_level(logging.WARNING, logger="blitztext.config"): + loaded = BlitztextConfig(config_dir=config_dir) + + assert loaded.llm_base_url == "" + assert loaded.has_unsafe_llm_base_url is True + records = [record for record in caplog.records if "unsafe LLM base URL" in record.message] + assert len(records) == 1 + assert unsafe_url not in records[0].message + + class TestTranscriptionHotkey: def test_valid_hotkey_is_accepted(self, config): config.transcription_hotkey = "KEY_F13" diff --git a/tests/test_settings_dialog.py b/tests/test_settings_dialog.py index deb76d1..7afad17 100644 --- a/tests/test_settings_dialog.py +++ b/tests/test_settings_dialog.py @@ -5,6 +5,8 @@ from types import SimpleNamespace from unittest.mock import Mock, patch +import pytest + from app.blitztext_linux import SettingsDialog from app.config import BlitztextConfig from app.i18n import DEFAULT_LANGUAGE, get_language, set_language @@ -189,6 +191,20 @@ def test_save_settings_persists_llm_provider_fields(tmp_path): assert reloaded.llm_model == "openai/gpt-4o" +def test_save_settings_rejects_public_http_base_url_without_saving_or_accepting(tmp_path): + config_dir = tmp_path / ".config" / "blitztext-linux" + fake = _fake_save_self(config_dir, "standard") + fake.edit_base_url = _Edit("http://api.example.com/v1") + fake.accept = Mock() + + with patch("app.blitztext_linux.QMessageBox") as message_box: + SettingsDialog.save_settings(fake) + + message_box.critical.assert_called_once() + fake.accept.assert_not_called() + assert not fake.config.config_file.exists() + + def test_save_settings_persists_and_applies_ui_language(tmp_path): config_dir = tmp_path / ".config" / "blitztext-linux" fake = _fake_save_self(config_dir, "standard", ui_language="en") @@ -286,6 +302,43 @@ def test_build_llm_service_ignores_base_url_when_provider_is_openai(tmp_path): assert service.model == "gpt-4o" +@pytest.mark.parametrize( + ("language", "expected_notice"), + [ + ("de", "Eine unsichere HTTP-Basis-URL aus der Konfiguration wurde entfernt. Verwende für öffentliche Endpunkte HTTPS."), + ("en", "An unsafe HTTP base URL from the configuration was removed. Use HTTPS for public endpoints."), + ], +) +def test_migrated_unsafe_base_url_shows_bilingual_notice_and_uses_empty_service_url( + tmp_path, language, expected_notice +): + from PyQt6.QtWidgets import QApplication + from app.blitztext_linux import BlitztextApp + + qapp = QApplication.instance() or QApplication([]) + config_dir = tmp_path / ".config" / "blitztext-linux" + config_dir.mkdir(parents=True, exist_ok=True) + (config_dir / "config.json").write_text( + json.dumps({"llm_provider": "custom", "llm_base_url": "http://api.example.com/v1"}), + encoding="utf-8", + ) + + try: + set_language(language) + config = BlitztextConfig(config_dir=config_dir) + dialog = SettingsDialog(config) + service = BlitztextApp._build_llm_service(SimpleNamespace(config=config)) + + assert config.has_unsafe_llm_base_url is True + assert dialog.lbl_unsafe_llm_base_url_notice is not None + assert dialog.lbl_unsafe_llm_base_url_notice.text() == expected_notice + assert service.base_url == "" + finally: + dialog.close() + qapp.processEvents() + set_language(DEFAULT_LANGUAGE) + + def test_provider_change_prefills_openrouter_base_url(): fake = SimpleNamespace( combo_llm_provider=_Combo(text="OpenRouter", data="openrouter"), From 1b9b94f9196b1ec8d799cee48a938962bbffa043 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=3D=3DTIM=2E=C2=A9=2EB=20=20=3D=3D?= Date: Mon, 31 Aug 2026 11:52:12 +0200 Subject: [PATCH 3/3] fix: harden LLM endpoint URL parsing --- app/config.py | 13 +++++++++---- tests/test_config.py | 23 +++++++++++++++++++++++ tests/test_settings_dialog.py | 23 +++++++++++++++++++++++ 3 files changed, 55 insertions(+), 4 deletions(-) diff --git a/app/config.py b/app/config.py index 62e3c7a..01a66ee 100644 --- a/app/config.py +++ b/app/config.py @@ -12,6 +12,7 @@ import logging import os import re +import unicodedata from pathlib import Path from typing import Any from urllib.parse import urlsplit @@ -550,7 +551,7 @@ def _normalize_base_url(value: Any) -> str: candidate = value.strip() if not candidate: return "" - if any(char.isspace() or ord(char) < 32 for char in candidate): + if any(char.isspace() or unicodedata.category(char) in {"Cc", "Cf"} for char in candidate): raise ValueError("LLM base URL must not contain whitespace or control characters") try: @@ -595,9 +596,13 @@ def _normalize_base_url(value: Any) -> str: def _looks_like_alternate_numeric_ipv4(hostname: str) -> bool: parts = hostname.split(".") - return bool(parts) and all(NUMERIC_HOST_PART_RE.fullmatch(part) for part in parts) and not ( - len(parts) == 4 and all(part.isdecimal() for part in parts) - ) + if not parts or not all(NUMERIC_HOST_PART_RE.fullmatch(part) for part in parts): + return False + try: + address = ipaddress.IPv4Address(hostname) + except ValueError: + return True + return str(address) != hostname def _normalize_model(value: Any) -> str: diff --git a/tests/test_config.py b/tests/test_config.py index c08baa5..fd8f7f3 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -236,12 +236,35 @@ def test_direct_setter_accepts_https_and_explicit_local_http_ranges(self, config "http://local\nhost/v1", "https://user:password@example.com/v1", "https://example.com:99999/v1", + "https://0177.0.0.1/v1", + "https://127.000.000.001/v1", + "https://999.1.1.1/v1", + "https://api.example.com/\x7f", + "https://api.example.com/\u0080", ], ) def test_direct_setter_rejects_unsafe_or_malformed_urls(self, config, value): with pytest.raises(ValueError, match="LLM base URL"): config.llm_base_url = value + @pytest.mark.parametrize( + "unsafe_url", + [ + "https://0177.0.0.1/v1", + "https://999.1.1.1/v1", + ], + ) + def test_stored_noncanonical_numeric_https_url_is_cleared(self, config_dir, unsafe_url): + config_dir.mkdir(parents=True, exist_ok=True) + (config_dir / "config.json").write_text( + json.dumps({"llm_base_url": unsafe_url}), encoding="utf-8" + ) + + loaded = BlitztextConfig(config_dir=config_dir) + + assert loaded.llm_base_url == "" + assert loaded.has_unsafe_llm_base_url is True + def test_legacy_public_http_url_is_cleared_and_warned_without_echoing_url(self, config_dir, caplog): config_dir.mkdir(parents=True, exist_ok=True) unsafe_url = "http://api.example.com/v1" diff --git a/tests/test_settings_dialog.py b/tests/test_settings_dialog.py index 7afad17..793b2f5 100644 --- a/tests/test_settings_dialog.py +++ b/tests/test_settings_dialog.py @@ -205,6 +205,29 @@ def test_save_settings_rejects_public_http_base_url_without_saving_or_accepting( assert not fake.config.config_file.exists() +@pytest.mark.parametrize( + "unsafe_url", + [ + "https://0177.0.0.1/v1", + "https://999.1.1.1/v1", + "https://api.example.com/\x7f", + "https://api.example.com/\u0080", + ], +) +def test_save_settings_rejects_invalid_https_url_without_saving_or_accepting(tmp_path, unsafe_url): + config_dir = tmp_path / ".config" / "blitztext-linux" + fake = _fake_save_self(config_dir, "standard") + fake.edit_base_url = _Edit(unsafe_url) + fake.accept = Mock() + + with patch("app.blitztext_linux.QMessageBox") as message_box: + SettingsDialog.save_settings(fake) + + message_box.critical.assert_called_once() + fake.accept.assert_not_called() + assert not fake.config.config_file.exists() + + def test_save_settings_persists_and_applies_ui_language(tmp_path): config_dir = tmp_path / ".config" / "blitztext-linux" fake = _fake_save_self(config_dir, "standard", ui_language="en")