Release v0.51.267 — Release II (stage-r17) (#3648)

## Release v0.51.267 — Release II (stage-r17)

Security hardening cluster — 3 @zapabob PRs (forwarded-header trust + TTS prosody validation).

### Security
| Issue/PR | Author | Hardening |
|----------|--------|-----------|
| #3640 | @zapabob | `/api/tts` per-client throttle no longer trusts `X-Forwarded-For` by default (can't spoof to evade the rate limit); forwarded IP honored only behind a trusted-proxy opt-in. |
| #3642 | @zapabob | CSRF same-origin check no longer trusts `X-Forwarded-Host`/`X-Real-Host` by default (closes a forwarded-host CSRF bypass); opt-in keeps legit reverse-proxy deploys working; default uses the real `Host`. |
| #3643 | @zapabob | Browser-provided TTS prosody (rate/pitch/volume) validated against the `±N%` / `±NHz` grammar before `edge_tts.Communicate`. |

### Attribution
Each contributor branch was **rebased onto current master and pushed back to @zapabob's fork** (native authorship preserved), so the source PRs are current/mergeable. Shipped here as one release because all three add a `[Unreleased]` CHANGELOG entry at the same location (merging individually would force a rebase-cascade). Source PRs #3640/#3642/#3643 closed as merged-via-release with credit.

### Gate
- Full pytest suite: **7779 passed, 0 failed**
- ruff: CLEAN
- revert-guard: PASS (all 3 branches rebased; master is an ancestor)
- Codex (regression): **SAFE TO SHIP** — each hardening is **default-secure AND opt-in-compatible** (no legit reverse-proxy/tunnel deploy breaks on update): CSRF forwarded-host default-off + opt-in works + normal same-origin still passes; TTS prosody rejects out-of-grammar input, legit `+N%` passes; TTS throttle ignores spoofed XFF by default.

Co-authored-by: zapabob <1920071390@campus.ouj.ac.jp>
This commit is contained in:
nesquena-hermesandzapabob authored and GitHub committed 2026-06-05 01:19:59 -07:00
1 parent 4c545a33f3
commit f1211e1f0c
5 files changed
+156 -12

No files matched your search

+8
View File
@@ -3,6 +3,14 @@
## [Unreleased]
## [v0.51.267] — 2026-06-04 — Release II (stage-r17 — TTS + CSRF forwarded-header security hardening)
### Security
- **The `/api/tts` per-client throttle no longer trusts `X-Forwarded-For` by default**, so a client can't spoof the header to evade its own rate limit (or pin another client's bucket). Forwarded IPs are honored only behind an explicit trusted-proxy opt-in. (#3640, @zapabob)
- **The CSRF same-origin check no longer trusts `X-Forwarded-Host` / `X-Real-Host` by default.** Those headers could previously let an attacker-set forwarded host satisfy the origin check; trusting them now requires an explicit opt-in, and the default uses the real `Host`. Legitimate reverse-proxy deployments keep working via the opt-in. (#3642, @zapabob)
- **Operator note:** if you run behind a reverse proxy that does NOT rewrite the `Host` header (it forwards the original host via `X-Forwarded-Host` instead), set `HERMES_WEBUI_TRUST_FORWARDED_HOST=1` after updating, or browser unsafe requests may be rejected by the CSRF check.
- **Browser-provided TTS prosody values (rate/pitch/volume) are now validated** against the expected `±N%` / `±NHz` grammar before being passed to `edge_tts.Communicate`, rejecting malformed input. (#3643, @zapabob)
## [v0.51.266] — 2026-06-04 — Release IH (stage-r16 — profile-chip active fix + intermediate reasoning + session-event scoping)
### Fixed
+33 -7
View File
@@ -1510,18 +1510,23 @@ def _check_csrf(handler) -> bool:
if origin_value in _allowed_public_origins():
origin_allowed = True
if not origin_allowed:
# Allow same-origin: check Host, X-Forwarded-Host (reverse proxy), and
# X-Real-Host against the origin. Reverse proxies (Caddy, nginx) set
# X-Forwarded-Host to the client's original Host header.
# Allow same-origin Host by default. Forwarded host headers are only
# trustworthy behind a proxy that strips untrusted inbound copies.
allowed_hosts = [
h.strip()
for h in [host]
if h.strip()
]
trust_forwarded_host = os.getenv("HERMES_WEBUI_TRUST_FORWARDED_HOST", "").strip().lower()
if trust_forwarded_host in ("1", "true", "yes", "on"):
allowed_hosts.extend(
h.strip()
for h in [
host,
handler.headers.get("X-Forwarded-Host", ""),
handler.headers.get("X-Real-Host", ""),
]
if h.strip()
]
)
for allowed in allowed_hosts:
allowed_name, allowed_port = _normalize_host_port(allowed)
if origin_name == allowed_name and _ports_match(origin_scheme, origin_port, allowed_port):
@@ -8984,6 +8989,18 @@ def _serve_file_bytes(handler, target: Path, mime: str, disposition: str, cache_
def _normalize_tts_prosody(value, *, unit: str) -> str | None:
if not value:
return ""
value = str(value).strip()
if not re.fullmatch(r"[+-]?\d{1,3}" + re.escape(unit), value):
return None
amount = int(value[: -len(unit)])
if -100 <= amount <= 100:
return value
return None
def _handle_tts(handler, parsed):
"""Generate TTS audio via Edge TTS. POST JSON body only.
@@ -9014,12 +9031,19 @@ def _handle_tts(handler, parsed):
data = read_body(handler)
text = (data.get("text") or "").strip()
voice = data.get("voice") or voice
rate_str = data.get("rate") or ""
pitch_str = data.get("pitch") or ""
rate_str = _normalize_tts_prosody(data.get("rate"), unit="%")
pitch_str = _normalize_tts_prosody(data.get("pitch"), unit="Hz")
except Exception:
from api.helpers import bad as _bad
return _bad(handler, "invalid request body", 400)
if rate_str is None:
from api.helpers import bad as _bad
return _bad(handler, "invalid rate", 400)
if pitch_str is None:
from api.helpers import bad as _bad
return _bad(handler, "invalid pitch", 400)
if not text:
from api.helpers import bad as _bad
return _bad(handler, "text is required", 400)
@@ -9047,6 +9071,8 @@ def _handle_tts(handler, parsed):
self._checks = 0
def _get_client_key(self, h):
trust_proxy = os.getenv("HERMES_WEBUI_TRUST_FORWARDED_FOR", "").strip().lower()
if trust_proxy in ("1", "true", "yes", "on"):
for hdr in ("X-Forwarded-For", "X-Real-IP", "Forwarded"):
val = h.headers.get(hdr)
if val:
+19
View File
@@ -89,6 +89,7 @@ def test_authenticated_reverse_proxy_same_origin_accepts_valid_csrf_token(monkey
cookie = _signed_cookie("g" * 64)
token = auth.csrf_token_for_session(cookie)
monkeypatch.setattr(auth, "is_auth_enabled", lambda: True)
monkeypatch.setenv("HERMES_WEBUI_TRUST_FORWARDED_HOST", "1")
try:
headers = {
"Origin": "https://example.com",
@@ -102,6 +103,24 @@ def test_authenticated_reverse_proxy_same_origin_accepts_valid_csrf_token(monkey
auth._sessions.pop("g" * 64, None)
def test_authenticated_forwarded_host_is_ignored_without_proxy_opt_in(monkeypatch):
cookie = _signed_cookie("h" * 64)
token = auth.csrf_token_for_session(cookie)
monkeypatch.setattr(auth, "is_auth_enabled", lambda: True)
monkeypatch.delenv("HERMES_WEBUI_TRUST_FORWARDED_HOST", raising=False)
try:
headers = {
"Origin": "https://example.com",
"Host": "127.0.0.1:8787",
"X-Forwarded-Host": "example.com:443",
"Cookie": f"{auth.COOKIE_NAME}={cookie}",
auth.CSRF_HEADER_NAME: token,
}
assert not routes._check_csrf(_FakeHandler(headers))
finally:
auth._sessions.pop("h" * 64, None)
def test_non_browser_mcp_style_authenticated_post_remains_compatible(monkeypatch):
cookie = _signed_cookie("d" * 64)
monkeypatch.setattr(auth, "is_auth_enabled", lambda: True)
+86
View File
@@ -7,6 +7,8 @@ edge_tts import / Communicate call.
"""
import io
import json
import sys
from types import SimpleNamespace
import pytest
@@ -65,6 +67,7 @@ def _fresh_tts_limiter(monkeypatch):
import api.auth as _auth
monkeypatch.setattr(_auth, "is_auth_enabled", lambda: False)
monkeypatch.setattr(routes, "is_auth_enabled", lambda: False, raising=False)
monkeypatch.delenv("HERMES_WEBUI_TRUST_FORWARDED_FOR", raising=False)
_reset_limiter()
yield
_reset_limiter()
@@ -97,6 +100,51 @@ def test_tts_rejects_unknown_voice():
assert "invalid voice" in (h.payload() or {}).get("error", "")
def test_tts_rejects_invalid_rate_before_engine():
h = _post({"text": "hello", "voice": "en-US-AriaNeural", "rate": "<break/>"}, client="10.0.0.6")
routes._handle_tts(h, None)
assert h.status == 400
assert "invalid rate" in (h.payload() or {}).get("error", "")
def test_tts_rejects_invalid_pitch_before_engine():
h = _post({"text": "hello", "voice": "en-US-AriaNeural", "pitch": "+500Hz"}, client="10.0.0.7")
routes._handle_tts(h, None)
assert h.status == 400
assert "invalid pitch" in (h.payload() or {}).get("error", "")
def test_tts_accepts_ui_prosody_shape(monkeypatch):
captured = {}
class FakeCommunicate:
def __init__(self, text, voice, **kwargs):
captured["text"] = text
captured["voice"] = voice
captured["kwargs"] = kwargs
def stream_sync(self):
yield {"type": "audio", "data": b"abc"}
monkeypatch.setitem(sys.modules, "edge_tts", SimpleNamespace(Communicate=FakeCommunicate))
h = _post(
{
"text": "hello",
"voice": "en-US-AriaNeural",
"rate": "+10%",
"pitch": "-5Hz",
},
client="10.0.0.8",
)
routes._handle_tts(h, None)
assert h.status == 200
assert captured["text"] == "hello"
assert captured["voice"] == "en-US-AriaNeural"
assert captured["kwargs"] == {"rate": "+10%", "pitch": "-5Hz"}
def test_tts_rate_limits_second_immediate_request():
# 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
@@ -110,3 +158,41 @@ def test_tts_rate_limits_second_immediate_request():
h2 = _post({"text": "hello", "voice": "not-a-real-voice"}, client="10.0.0.3")
routes._handle_tts(h2, None)
assert h2.status == 429
def test_tts_rate_limit_ignores_spoofed_forwarded_for_by_default():
h1 = _post(
{"text": "hello", "voice": "not-a-real-voice"},
headers={"X-Forwarded-For": "203.0.113.10"},
client="10.0.0.4",
)
routes._handle_tts(h1, None)
assert h1.status == 400
h2 = _post(
{"text": "hello", "voice": "not-a-real-voice"},
headers={"X-Forwarded-For": "203.0.113.11"},
client="10.0.0.4",
)
routes._handle_tts(h2, None)
assert h2.status == 429
def test_tts_rate_limit_can_trust_forwarded_for_when_opted_in(monkeypatch):
monkeypatch.setenv("HERMES_WEBUI_TRUST_FORWARDED_FOR", "1")
h1 = _post(
{"text": "hello", "voice": "not-a-real-voice"},
headers={"X-Forwarded-For": "203.0.113.12"},
client="10.0.0.5",
)
routes._handle_tts(h1, None)
assert h1.status == 400
h2 = _post(
{"text": "hello", "voice": "not-a-real-voice"},
headers={"X-Forwarded-For": "203.0.113.13"},
client="10.0.0.5",
)
routes._handle_tts(h2, None)
assert h2.status == 400
+10 -5
View File
@@ -119,7 +119,7 @@ class TestCSRF:
)
assert status != 403, f"Expected non-403 for same-referer request, got {status}"
def test_proxy_host_default_https_port_matches_http_origin(self):
def test_proxy_host_default_https_port_matches_http_origin(self, monkeypatch):
"""http:// origin without port must NOT match X-Forwarded-Host with :443.
After the scheme-aware _ports_match fix: http:// absent port = :80,
@@ -129,20 +129,23 @@ class TestCSRF:
See test_proxy_host_default_https_port_matches_https_origin for the
real-world proxy case that should pass.
"""
monkeypatch.setenv("HERMES_WEBUI_TRUST_FORWARDED_HOST", "1")
assert not self._csrf_allowed({
"Origin": "http://example.com",
"X-Forwarded-Host": "example.com:443",
}), 'http origin (port :80) must not match https host (:443)'
def test_proxy_host_default_https_port_matches_https_origin(self):
def test_proxy_host_default_https_port_matches_https_origin(self, monkeypatch):
"""HTTPS Origin without port should match X-Forwarded-Host with explicit :443."""
monkeypatch.setenv("HERMES_WEBUI_TRUST_FORWARDED_HOST", "1")
assert self._csrf_allowed({
"Origin": "https://example.com",
"X-Forwarded-Host": "example.com:443",
})
def test_proxy_host_port_normalization_still_rejects_other_host(self):
def test_proxy_host_port_normalization_still_rejects_other_host(self, monkeypatch):
"""Port normalization must not allow different hosts through."""
monkeypatch.setenv("HERMES_WEBUI_TRUST_FORWARDED_HOST", "1")
assert not self._csrf_allowed({
"Origin": "https://evil.com",
"X-Forwarded-Host": "example.com:443",
@@ -168,19 +171,21 @@ class TestCSRF:
# ── Port normalization: scheme-aware (M-1 fix) ────────────────────────────
def test_cross_protocol_port_not_confused_http_origin_https_host(self):
def test_cross_protocol_port_not_confused_http_origin_https_host(self, monkeypatch):
"""http:// origin must NOT match a host with :443 (HTTPS default).
Before M-1 fix, _ports_match treated both 80 and 443 as equivalent to
absent port, allowing http://host to match https://host:443 servers.
"""
monkeypatch.setenv("HERMES_WEBUI_TRUST_FORWARDED_HOST", "1")
assert not self._csrf_allowed({
'Origin': 'http://example.com', # http, no port = :80
'X-Forwarded-Host': 'example.com:443', # HTTPS port
}), 'http origin should NOT match host advertising port 443'
def test_cross_protocol_port_not_confused_https_origin_http_host(self):
def test_cross_protocol_port_not_confused_https_origin_http_host(self, monkeypatch):
"""https:// origin must NOT match a host with :80 (HTTP default)."""
monkeypatch.setenv("HERMES_WEBUI_TRUST_FORWARDED_HOST", "1")
assert not self._csrf_allowed({
'Origin': 'https://example.com', # https, no port = :443
'X-Forwarded-Host': 'example.com:80', # HTTP port