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:
1 parent
4c545a33f3
commit
f1211e1f0c
5 files changed
+165
-21
No files matched your search
@@ -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
|
||||
|
||||
+42
-16
@@ -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,
|
||||
handler.headers.get("X-Forwarded-Host", ""),
|
||||
handler.headers.get("X-Real-Host", ""),
|
||||
]
|
||||
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 [
|
||||
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,12 +9071,14 @@ def _handle_tts(handler, parsed):
|
||||
self._checks = 0
|
||||
|
||||
def _get_client_key(self, h):
|
||||
for hdr in ("X-Forwarded-For", "X-Real-IP", "Forwarded"):
|
||||
val = h.headers.get(hdr)
|
||||
if val:
|
||||
ip = val.split(",")[0].strip().split(";")[0].strip()
|
||||
if ip:
|
||||
return ip
|
||||
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:
|
||||
ip = val.split(",")[0].strip().split(";")[0].strip()
|
||||
if ip:
|
||||
return ip
|
||||
return getattr(h, "client_address", ("unknown",))[0]
|
||||
|
||||
def check(self, handler, session_cookie=None):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user