Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 27 additions & 2 deletions hermes_cli/web_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down
32 changes: 32 additions & 0 deletions tests/hermes_cli/test_web_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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