Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion SUPPORT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
13 changes: 12 additions & 1 deletion app/blitztext_linux.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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")))
Expand Down
82 changes: 76 additions & 6 deletions app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,15 @@
from __future__ import annotations

import copy
import ipaddress
import json
import logging
import os
import re
import unicodedata
from pathlib import Path
from typing import Any
from urllib.parse import urlsplit

from app.writing_presets import (
DEFAULT_PRESET_KEY,
Expand Down Expand Up @@ -69,7 +72,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",
Expand All @@ -93,6 +101,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()
Expand Down Expand Up @@ -144,6 +153,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

Expand All @@ -170,6 +180,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"])
Expand All @@ -183,7 +197,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:
Expand Down Expand Up @@ -489,7 +503,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
Comment on lines +508 to +510

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Block migrated endpoints instead of defaulting to OpenAI

When a legacy custom or openrouter configuration contains a public HTTP URL and an API key is set, clearing only llm_base_url leaves the non-OpenAI provider selected. _build_llm_service() then passes the empty URL to LLMService, which converts it to base_url=None; the OpenAI SDK consequently uses its default OpenAI endpoint. An LLM workflow can therefore send the transcript and custom-provider credential to OpenAI before the user ever sees the settings warning. Preserve an explicit blocked state or otherwise prevent requests until the user supplies a valid endpoint.

Useful? React with 👍 / 👎.

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):
Expand Down Expand Up @@ -528,11 +547,62 @@ 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 unicodedata.category(char) in {"Cc", "Cf"} 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
Comment on lines +581 to +584

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate the endpoint before mutating the live configuration

When a user enters a public HTTP endpoint, this new exception is raised only after SettingsDialog.save_settings() has already assigned the model, backend, language, hotkey, API-key environment, and provider to the shared config object. The error dialog prevents saving and acceptance, but it does not roll those assignments back; cancelling afterward leaves partial settings active in memory, and a later preset change calls config.save() and persists them. Validate the URL before any assignments or stage changes in a copy until all validation succeeds.

Useful? React with 👍 / 👎.


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(".")
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:
Expand Down
35 changes: 1 addition & 34 deletions app/hotkey_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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", "<unknown>"), 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:
Expand Down Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions app/i18n.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:",
Expand Down Expand Up @@ -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:",
Expand Down
6 changes: 3 additions & 3 deletions docs/privacy.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
82 changes: 82 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,88 @@ 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",
"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"
(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"
Expand Down
Loading