Files
hermes-webui/tests/test_auth.py
T
nesquena-hermes ba987040c7 Release v0.51.260 — Release IB (stage-r8) (#3614)
## 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>
2026-06-04 15:21:41 -07:00

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