## 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>
188 lines
6.4 KiB
Python
188 lines
6.4 KiB
Python
"""Regression tests for #1909 session-bound CSRF token first slice."""
|
|
|
|
import hmac
|
|
import io
|
|
import time
|
|
from types import SimpleNamespace
|
|
|
|
import api.auth as auth
|
|
import api.routes as routes
|
|
|
|
|
|
def _signed_cookie(raw_token: str) -> str:
|
|
sig = hmac.new(auth._signing_key(), raw_token.encode(), "sha256").hexdigest()
|
|
auth._sessions[raw_token] = time.time() + 60
|
|
return f"{raw_token}.{sig}"
|
|
|
|
|
|
class _FakeHandler:
|
|
def __init__(self, headers=None, body=b"{}"):
|
|
self.headers = headers or {}
|
|
self.client_address = ("127.0.0.1", 12345)
|
|
self.rfile = io.BytesIO(body)
|
|
self.wfile = io.BytesIO()
|
|
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 test_csrf_token_is_bound_to_auth_session():
|
|
cookie_a = _signed_cookie("a" * 64)
|
|
cookie_b = _signed_cookie("b" * 64)
|
|
try:
|
|
token_a = auth.csrf_token_for_session(cookie_a)
|
|
token_b = auth.csrf_token_for_session(cookie_b)
|
|
|
|
assert token_a and token_b and token_a != token_b
|
|
assert auth.verify_csrf_token(cookie_a, token_a)
|
|
assert not auth.verify_csrf_token(cookie_b, token_a)
|
|
assert not auth.verify_csrf_token(cookie_a, "not-the-token")
|
|
finally:
|
|
auth._sessions.pop("a" * 64, None)
|
|
auth._sessions.pop("b" * 64, None)
|
|
|
|
|
|
def test_authenticated_same_origin_browser_post_requires_session_csrf_token(monkeypatch):
|
|
cookie = _signed_cookie("c" * 64)
|
|
token = auth.csrf_token_for_session(cookie)
|
|
monkeypatch.setattr(auth, "is_auth_enabled", lambda: True)
|
|
try:
|
|
base_headers = {
|
|
"Origin": "http://127.0.0.1:8787",
|
|
"Host": "127.0.0.1:8787",
|
|
"Cookie": f"{auth.COOKIE_NAME}={cookie}",
|
|
}
|
|
assert not routes._check_csrf(_FakeHandler(base_headers.copy()))
|
|
|
|
headers_with_token = {**base_headers, auth.CSRF_HEADER_NAME: token}
|
|
assert routes._check_csrf(_FakeHandler(headers_with_token))
|
|
finally:
|
|
auth._sessions.pop("c" * 64, None)
|
|
|
|
|
|
def test_authenticated_allowed_public_origin_accepts_valid_csrf_token(monkeypatch):
|
|
cookie = _signed_cookie("f" * 64)
|
|
token = auth.csrf_token_for_session(cookie)
|
|
monkeypatch.setattr(auth, "is_auth_enabled", lambda: True)
|
|
monkeypatch.setenv("HERMES_WEBUI_ALLOWED_ORIGINS", "https://myapp.example.com:8000")
|
|
try:
|
|
headers = {
|
|
"Origin": "https://myapp.example.com:8000",
|
|
"Host": "proxy.internal",
|
|
"Cookie": f"{auth.COOKIE_NAME}={cookie}",
|
|
auth.CSRF_HEADER_NAME: token,
|
|
}
|
|
assert routes._check_csrf(_FakeHandler(headers))
|
|
finally:
|
|
auth._sessions.pop("f" * 64, None)
|
|
|
|
|
|
def test_authenticated_reverse_proxy_same_origin_accepts_valid_csrf_token(monkeypatch):
|
|
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",
|
|
"Host": "127.0.0.1:8787",
|
|
"X-Forwarded-Host": "example.com:443",
|
|
"Cookie": f"{auth.COOKIE_NAME}={cookie}",
|
|
auth.CSRF_HEADER_NAME: token,
|
|
}
|
|
assert routes._check_csrf(_FakeHandler(headers))
|
|
finally:
|
|
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)
|
|
try:
|
|
handler = _FakeHandler({"Cookie": f"{auth.COOKIE_NAME}={cookie}"})
|
|
assert routes._check_csrf(handler)
|
|
finally:
|
|
auth._sessions.pop("d" * 64, None)
|
|
|
|
|
|
def test_login_route_remains_csrf_exempt(monkeypatch):
|
|
handler = _FakeHandler(
|
|
{
|
|
"Content-Length": "2",
|
|
"Content-Type": "application/json",
|
|
"Origin": "http://evil.example",
|
|
"Host": "127.0.0.1:8787",
|
|
}
|
|
)
|
|
|
|
def fail_if_called(_handler):
|
|
raise AssertionError("/api/auth/login must not require a pre-login CSRF token")
|
|
|
|
monkeypatch.setattr(routes, "_check_csrf", fail_if_called)
|
|
monkeypatch.setattr(auth, "is_auth_enabled", lambda: False)
|
|
|
|
routes.handle_post(handler, SimpleNamespace(path="/api/auth/login"))
|
|
assert handler.status == 200
|
|
|
|
|
|
def test_index_shell_includes_csrf_fetch_and_sendbeacon_injection():
|
|
src = (routes._INDEX_HTML_PATH).read_text(encoding="utf-8")
|
|
|
|
assert "csrfToken:__CSRF_TOKEN_JSON__" in src
|
|
assert "X-Hermes-CSRF-Token" in src
|
|
assert "window.fetch=function" in src
|
|
assert "navigator.sendBeacon=function" in src
|
|
assert "auth\\/login|csp-report" in src
|
|
|
|
|
|
def test_index_shell_injects_session_bound_csrf_token(monkeypatch):
|
|
cookie = _signed_cookie("e" * 64)
|
|
token = auth.csrf_token_for_session(cookie)
|
|
monkeypatch.setattr(auth, "is_auth_enabled", lambda: True)
|
|
|
|
captured = {}
|
|
|
|
def fake_t(_handler, body, *, content_type=None, **_kwargs):
|
|
captured["body"] = body
|
|
captured["content_type"] = content_type
|
|
return True
|
|
|
|
import api.extensions as extensions
|
|
|
|
monkeypatch.setattr(routes, "t", fake_t)
|
|
monkeypatch.setattr(extensions, "inject_extension_tags", lambda html: html)
|
|
|
|
try:
|
|
handler = _FakeHandler({"Cookie": f"{auth.COOKIE_NAME}={cookie}"})
|
|
assert routes.handle_get(handler, SimpleNamespace(path="/", query="")) is True
|
|
assert captured["content_type"] == "text/html; charset=utf-8"
|
|
assert f"csrfToken:{token!r}".replace("'", '"') in captured["body"]
|
|
finally:
|
|
auth._sessions.pop("e" * 64, None)
|