mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-07-16 04:30:17 +00:00
ba987040c7
## Release v0.51.260 — Release IB (stage-r8) Un-held safety fixes (author resolved my earlier hold findings; re-reviewed fresh) + a clean fix batch. 6 PRs. ### Fixed | Issue/PR | Author | Fix | |----------|--------|-----| | #3535 (#3538) | @rodboev | **Self-update recovers from a stash-pop conflict without data loss.** Was a BRICK bug (`git reset --merge` + `git stash drop` discarded local mods while reporting success). Now keeps the stash, returns `ok:false` + "preserved in `stash@{0}`", no restart on conflict. *(was held — fix verified)* | | #1909 s3 (#3562) | @rodboev | **Auth `Secure` cookie no longer locks out plain-HTTP LAN/Tailscale users.** Secure now keys only on real TLS evidence (env / TLS socket / opt-in `TRUST_FORWARDED_PROTO`); non-loopback plain-HTTP is no longer force-Secure. SameSite back to `Lax`. *(was held — fix verified)* | | #2785 (#3559) | @franksong2702 | Clearer cron/gateway diagnostics for single-container Docker (gateway configured, no daemon → jobs silently don't fire). | | #3555 | @lambyangzhao | Long TTS responses chunked at sentence boundaries (works around the browser's ~32K silent-truncation). | | #3340 (#3342) | @rly09 | Persistent-state toast when a turn has saved memory / created-updated a skill. | | #3533 | @franksong2702 | `/reload-mcp` marked `cli_only` so the WebUI doesn't dispatch it as an LLM prompt. | ### Gate - Full pytest suite: **7681 passed, 0 failed** - ESLint: CLEAN · ruff: CLEAN · browser-smoke: CLEAN - Codex (regression): **SAFE TO SHIP** — confirmed the stash-conflict path never drops the stash / never restarts on conflict, auth Secure handles LAN-HTTP correctly with no header-forgery hole, `/reload-mcp` allowlisted, state-toast has a real backend writer + active-session guard, diagnostics leak no paths, TTS chunking preserves order. Co-authored-by: rodboev <rodboev@users.noreply.github.com> Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com> Co-authored-by: lambyangzhao <lambyangzhao@users.noreply.github.com> Co-authored-by: rly09 <rly09@users.noreply.github.com>
193 lines
6.9 KiB
Python
193 lines
6.9 KiB
Python
"""Unit tests for cookie security hardening (Issue #1909, Slice 3).
|
|
|
|
Covers:
|
|
- SameSite=Lax on the auth cookie
|
|
- _is_loopback() helper
|
|
- _is_secure_context() priority logic:
|
|
1. HERMES_WEBUI_SECURE override
|
|
2. Direct TLS (getpeercert)
|
|
3. HERMES_WEBUI_TRUST_FORWARDED_PROTO opt-in for X-Forwarded-Proto
|
|
4. Otherwise -> not Secure (plain HTTP, regardless of client address)
|
|
"""
|
|
|
|
import http.cookies
|
|
import io
|
|
|
|
from api.auth import _is_loopback, _is_secure_context, set_auth_cookie, COOKIE_NAME
|
|
|
|
|
|
# ── Mock handler helpers ─────────────────────────────────────────────────────
|
|
|
|
|
|
class _MockRequest:
|
|
"""Fake socket-like request object."""
|
|
def __init__(self, *, has_peercert: bool = False):
|
|
if has_peercert:
|
|
self.getpeercert = lambda: {'subject': ()}
|
|
|
|
|
|
class _MockHandler:
|
|
"""Minimal BaseHTTPRequestHandler stand-in."""
|
|
|
|
def __init__(
|
|
self,
|
|
client_address=('127.0.0.1', 12345),
|
|
headers=None,
|
|
request=None,
|
|
):
|
|
self.client_address = client_address
|
|
self.headers = headers or {}
|
|
self.request = request
|
|
self.status = None
|
|
self.sent_headers = []
|
|
self.body = bytearray()
|
|
self.wfile = self
|
|
self.rfile = io.BytesIO(b'')
|
|
|
|
def send_response(self, status):
|
|
self.status = status
|
|
|
|
def send_header(self, name, value):
|
|
self.sent_headers.append((name, value))
|
|
|
|
def end_headers(self):
|
|
pass
|
|
|
|
def write(self, data):
|
|
self.body.extend(data)
|
|
|
|
def _set_cookie_header(self):
|
|
for name, val in self.sent_headers:
|
|
if name == 'Set-Cookie':
|
|
return val
|
|
return ''
|
|
|
|
|
|
# ── _is_loopback tests ───────────────────────────────────────────────────────
|
|
|
|
|
|
def test_is_loopback_127_0_0_1():
|
|
assert _is_loopback('127.0.0.1') is True
|
|
|
|
|
|
def test_is_loopback_127_255_255_255():
|
|
assert _is_loopback('127.255.255.255') is True
|
|
|
|
|
|
def test_is_loopback_ipv6_loopback():
|
|
assert _is_loopback('::1') is True
|
|
|
|
|
|
def test_is_loopback_ipv4_mapped_ipv6_loopback():
|
|
assert _is_loopback('::ffff:127.0.0.1') is True
|
|
|
|
|
|
def test_is_loopback_private_not_loopback():
|
|
assert _is_loopback('10.0.0.1') is False
|
|
|
|
|
|
def test_is_loopback_rfc1918_not_loopback():
|
|
assert _is_loopback('192.168.1.1') is False
|
|
|
|
|
|
# ── samesite=Lax tests ──────────────────────────────────────────────────────
|
|
|
|
|
|
def test_samesite_lax_in_cookie(monkeypatch):
|
|
"""set_auth_cookie must emit SameSite=Lax."""
|
|
monkeypatch.delenv('HERMES_WEBUI_SECURE', raising=False)
|
|
handler = _MockHandler()
|
|
set_auth_cookie(handler, 'test-token-value')
|
|
cookie_header = handler._set_cookie_header()
|
|
assert cookie_header, "Set-Cookie header was not sent"
|
|
# Parse via http.cookies to be case/whitespace independent
|
|
c = http.cookies.SimpleCookie()
|
|
c.load(cookie_header)
|
|
assert COOKIE_NAME in c
|
|
assert c[COOKIE_NAME]['samesite'].lower() == 'lax'
|
|
|
|
|
|
# ── _is_secure_context tests ─────────────────────────────────────────────────
|
|
|
|
|
|
def test_secure_not_set_for_loopback(monkeypatch):
|
|
"""Loopback client with no TLS and no env vars → not secure."""
|
|
monkeypatch.delenv('HERMES_WEBUI_SECURE', raising=False)
|
|
monkeypatch.delenv('HERMES_WEBUI_TRUST_FORWARDED_PROTO', raising=False)
|
|
handler = _MockHandler(client_address=('127.0.0.1', 9999))
|
|
assert _is_secure_context(handler) is False
|
|
|
|
|
|
def test_plain_http_non_loopback_not_secure(monkeypatch):
|
|
"""Plain HTTP from a LAN IP must NOT set Secure. Regression test for PR #3562."""
|
|
monkeypatch.delenv('HERMES_WEBUI_SECURE', raising=False)
|
|
monkeypatch.delenv('HERMES_WEBUI_TRUST_FORWARDED_PROTO', raising=False)
|
|
handler = _MockHandler(client_address=('10.0.0.1', 9999))
|
|
assert _is_secure_context(handler) is False
|
|
|
|
|
|
def test_plain_http_rfc1918_class_a_not_secure(monkeypatch):
|
|
"""192.168.x.x over plain HTTP must not be secure."""
|
|
monkeypatch.delenv('HERMES_WEBUI_SECURE', raising=False)
|
|
monkeypatch.delenv('HERMES_WEBUI_TRUST_FORWARDED_PROTO', raising=False)
|
|
handler = _MockHandler(client_address=('192.168.1.50', 9999))
|
|
assert _is_secure_context(handler) is False
|
|
|
|
|
|
def test_plain_http_tailscale_not_secure(monkeypatch):
|
|
"""Tailscale CGNAT range (100.64.x.x) over plain HTTP must not be secure."""
|
|
monkeypatch.delenv('HERMES_WEBUI_SECURE', raising=False)
|
|
monkeypatch.delenv('HERMES_WEBUI_TRUST_FORWARDED_PROTO', raising=False)
|
|
handler = _MockHandler(client_address=('100.64.0.1', 9999))
|
|
assert _is_secure_context(handler) is False
|
|
|
|
|
|
def test_trust_forwarded_proto_opt_in(monkeypatch):
|
|
"""With opt-in env var set and X-Forwarded-Proto: https → secure."""
|
|
monkeypatch.delenv('HERMES_WEBUI_SECURE', raising=False)
|
|
monkeypatch.setenv('HERMES_WEBUI_TRUST_FORWARDED_PROTO', '1')
|
|
handler = _MockHandler(
|
|
client_address=('127.0.0.1', 9999),
|
|
headers={'X-Forwarded-Proto': 'https'},
|
|
)
|
|
assert _is_secure_context(handler) is True
|
|
|
|
|
|
def test_forwarded_proto_ignored_without_opt_in(monkeypatch):
|
|
"""Without opt-in, X-Forwarded-Proto: https on loopback is ignored → not secure."""
|
|
monkeypatch.delenv('HERMES_WEBUI_SECURE', raising=False)
|
|
monkeypatch.delenv('HERMES_WEBUI_TRUST_FORWARDED_PROTO', raising=False)
|
|
handler = _MockHandler(
|
|
client_address=('127.0.0.1', 9999),
|
|
headers={'X-Forwarded-Proto': 'https'},
|
|
)
|
|
assert _is_secure_context(handler) is False
|
|
|
|
|
|
def test_hermes_webui_secure_override_on(monkeypatch):
|
|
"""HERMES_WEBUI_SECURE=1 forces secure True regardless of other conditions."""
|
|
monkeypatch.setenv('HERMES_WEBUI_SECURE', '1')
|
|
# Loopback, no TLS, no forwarded-proto opt-in; override must win
|
|
handler = _MockHandler(client_address=('127.0.0.1', 9999))
|
|
assert _is_secure_context(handler) is True
|
|
|
|
|
|
def test_hermes_webui_secure_override_off(monkeypatch):
|
|
"""HERMES_WEBUI_SECURE=0 forces secure False regardless of other conditions."""
|
|
monkeypatch.setenv('HERMES_WEBUI_SECURE', '0')
|
|
# Explicit override must win even for non-loopback addresses
|
|
handler = _MockHandler(client_address=('10.0.0.1', 9999))
|
|
assert _is_secure_context(handler) is False
|
|
|
|
|
|
def test_direct_tls_socket_is_secure(monkeypatch):
|
|
"""Direct TLS socket (getpeercert present) → secure, regardless of address."""
|
|
monkeypatch.delenv('HERMES_WEBUI_SECURE', raising=False)
|
|
monkeypatch.delenv('HERMES_WEBUI_TRUST_FORWARDED_PROTO', raising=False)
|
|
tls_request = _MockRequest(has_peercert=True)
|
|
handler = _MockHandler(
|
|
client_address=('127.0.0.1', 9999),
|
|
request=tls_request,
|
|
)
|
|
assert _is_secure_context(handler) is True
|