From 0171561a18bd4dfe293bee5ca331c1e7298ed437 Mon Sep 17 00:00:00 2001 From: InphinitiZ Date: Wed, 27 May 2026 10:20:33 +0800 Subject: [PATCH] feat(dashboard): allow LAN clients via HERMES_DASHBOARD_TRUST_LAN env --- hermes_cli/web_server.py | 29 ++++++++++++++++++++++++-- tests/hermes_cli/test_web_server.py | 32 +++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index d8d7996b868e..33a395bf17b3 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -3303,6 +3303,7 @@ async def get_models_analytics(days: int = 30): import re import asyncio +import ipaddress # PTY bridge is POSIX-only (depends on fcntl/termios/ptyprocess). On native # Windows the import raises; catch and leave PtyBridge=None so the rest of @@ -3326,16 +3327,40 @@ class PtyUnavailableError(RuntimeError): # type: ignore[no-redef] # loopback so tests don't need to rewrite request scope. _LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost", "testclient"}) +_TRUST_LAN_ENV = "HERMES_DASHBOARD_TRUST_LAN" + + +def _is_rfc1918_or_ula(host: str) -> bool: + """Return True if *host* parses as an RFC1918 (10/8, 172.16/12, 192.168/16) + or IPv6 ULA (fc00::/7) address. Invalid input returns False.""" + try: + ip = ipaddress.ip_address(host) + except (ValueError, TypeError): + return False + if isinstance(ip, ipaddress.IPv4Address): + return ip in ipaddress.ip_network("10.0.0.0/8") \ + or ip in ipaddress.ip_network("172.16.0.0/12") \ + or ip in ipaddress.ip_network("192.168.0.0/16") + return ip in ipaddress.ip_network("fc00::/7") + def _ws_client_is_allowed(ws: "WebSocket") -> bool: """Check if the WebSocket client IP is acceptable. - Allows loopback clients only. + Loopback is always allowed. When the ``HERMES_DASHBOARD_TRUST_LAN`` + environment variable is set to a truthy value (``1``/``true``/``yes``), + RFC1918 and IPv6 ULA clients are also allowed; this is intended for + reverse-proxy deployments where the dashboard sits behind a separate + LAN gateway. Public addresses are always rejected. """ client_host = ws.client.host if ws.client else "" if not client_host: return True - return client_host in _LOOPBACK_HOSTS + if client_host in _LOOPBACK_HOSTS: + return True + if os.environ.get(_TRUST_LAN_ENV, "").strip().lower() in {"1", "true", "yes"}: + return _is_rfc1918_or_ula(client_host) + return False def _ws_host_origin_is_allowed(ws: "WebSocket") -> bool: diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 13e8001e7d07..a82d51b05c19 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -2450,3 +2450,35 @@ def test_path_traversal_still_blocked(self): # — never 200. assert resp.status_code in (403, 404) + +def test_ws_lan_client_rejected_by_default(monkeypatch): + from types import SimpleNamespace + from hermes_cli.web_server import _ws_client_is_allowed + monkeypatch.delenv("HERMES_DASHBOARD_TRUST_LAN", raising=False) + ws = SimpleNamespace(client=SimpleNamespace(host="192.168.1.10")) + assert _ws_client_is_allowed(ws) is False + + +def test_ws_lan_client_allowed_with_trust_env(monkeypatch): + from types import SimpleNamespace + from hermes_cli.web_server import _ws_client_is_allowed + monkeypatch.setenv("HERMES_DASHBOARD_TRUST_LAN", "1") + ws = SimpleNamespace(client=SimpleNamespace(host="192.168.1.10")) + assert _ws_client_is_allowed(ws) is True + + +def test_ws_public_client_rejected_even_with_trust_env(monkeypatch): + from types import SimpleNamespace + from hermes_cli.web_server import _ws_client_is_allowed + monkeypatch.setenv("HERMES_DASHBOARD_TRUST_LAN", "1") + ws = SimpleNamespace(client=SimpleNamespace(host="8.8.8.8")) + assert _ws_client_is_allowed(ws) is False + + +def test_ws_loopback_always_allowed(monkeypatch): + from types import SimpleNamespace + from hermes_cli.web_server import _ws_client_is_allowed + monkeypatch.delenv("HERMES_DASHBOARD_TRUST_LAN", raising=False) + ws = SimpleNamespace(client=SimpleNamespace(host="127.0.0.1")) + assert _ws_client_is_allowed(ws) is True +