diff --git a/api/routes.py b/api/routes.py index f16f8b8f..7f4df437 100644 --- a/api/routes.py +++ b/api/routes.py @@ -8273,7 +8273,7 @@ def _handle_tts(handler, parsed): import edge_tts except ImportError: from api.helpers import bad as _bad - return _bad(handler, "edge-tts not installed — see docs", 503) + return _bad(handler, "Edge TTS engine not installed on the server. Install it with: pip install edge-tts", 503) kwargs = {} if rate_str: diff --git a/requirements.txt b/requirements.txt index e09a4ff5..0477bb7c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,10 @@ # Hermes Web UI -- minimal Python dependencies # The server uses PyYAML plus cryptography for optional local passkey/WebAuthn support. # All heavy ML/agent deps live in the Hermes agent venv. +# +# OPTIONAL: the Edge TTS speech engine (Settings -> Voice -> TTS Engine -> "Edge TTS") +# needs `edge-tts`. It is intentionally NOT a hard dependency — the /api/tts +# endpoint returns 503 with an install hint when it's absent. Install it only if +# you want server-side Microsoft neural voices: pip install edge-tts pyyaml>=6.0 -edge-tts cryptography>=42.0 diff --git a/tests/test_issue2931_edge_tts_endpoint.py b/tests/test_issue2931_edge_tts_endpoint.py new file mode 100644 index 00000000..a1b7f2f6 --- /dev/null +++ b/tests/test_issue2931_edge_tts_endpoint.py @@ -0,0 +1,97 @@ +"""Validation + security-path coverage for the Edge TTS endpoint (#2931). + +These exercise the guard rails of _handle_tts (method, input cap, voice +allowlist, rate limiting) in-process via a fake handler — no network and no +real edge-tts synthesis required, since every rejection happens before the +edge_tts import / Communicate call. +""" +import io +import json + +import api.routes as routes + + +class _FakeHandler: + def __init__(self, body: bytes, command: str = "POST", headers=None, client="1.2.3.4"): + self.command = command + self.rfile = io.BytesIO(body) + self.wfile = io.BytesIO() + self.headers = headers or {} + self.headers.setdefault("Content-Length", str(len(body))) + self.client_address = (client, 12345) + self.status = None + self.sent_headers = {} + + def send_response(self, status): + self.status = status + + def send_header(self, key, value): + self.sent_headers[key] = value + + def end_headers(self): + pass + + def payload(self): + try: + return json.loads(self.wfile.getvalue().decode("utf-8")) + except Exception: + return None + + +def _post(body_dict, **kw): + body = json.dumps(body_dict).encode() + return _FakeHandler(body, **kw) + + +def _reset_limiter(): + # Drop any limiter state carried between tests so rate-limit assertions are + # deterministic regardless of run order. + if hasattr(routes._handle_tts, "_tts_limiter"): + del routes._handle_tts._tts_limiter + + +def test_tts_requires_post(): + _reset_limiter() + h = _post({"text": "hello"}, command="GET") + routes._handle_tts(h, None) + assert h.status == 405 + + +def test_tts_requires_text(): + _reset_limiter() + h = _post({"text": " "}) + routes._handle_tts(h, None) + assert h.status == 400 + assert "text is required" in (h.payload() or {}).get("error", "") + + +def test_tts_rejects_overlong_text(): + _reset_limiter() + h = _post({"text": "x" * 5001}) + routes._handle_tts(h, None) + assert h.status == 400 + assert "too long" in (h.payload() or {}).get("error", "") + + +def test_tts_rejects_unknown_voice(): + _reset_limiter() + h = _post({"text": "hello", "voice": "evil-voice-injection"}, client="5.6.7.8") + routes._handle_tts(h, None) + assert h.status == 400 + assert "invalid voice" in (h.payload() or {}).get("error", "") + + +def test_tts_rate_limits_second_immediate_request(): + _reset_limiter() + # The limiter runs (and records the client) BEFORE the voice allowlist and + # before any edge-tts synthesis. Use an invalid voice so the first request + # still registers with the limiter but returns at the allowlist (400) without + # making a real network call; the second immediate request from the SAME + # client is then throttled (429). + h1 = _post({"text": "hello", "voice": "not-a-real-voice"}, client="9.9.9.9") + routes._handle_tts(h1, None) + assert h1.status == 400 # rejected at allowlist, limiter recorded the client + h2 = _post({"text": "hello", "voice": "not-a-real-voice"}, client="9.9.9.9") + routes._handle_tts(h2, None) + assert h2.status == 429 + _reset_limiter()