diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index d8d7996b868e..f7418f79c2c2 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -3330,12 +3330,32 @@ class PtyUnavailableError(RuntimeError): # type: ignore[no-redef] def _ws_client_is_allowed(ws: "WebSocket") -> bool: """Check if the WebSocket client IP is acceptable. - Allows loopback clients only. + Allows loopback clients by default. When the dashboard is explicitly bound + to a non-loopback hostname/IP (for example a Tailscale name used behind + ``tailscale serve``), also allow clients that arrive from that bound address. + Host/Origin validation still runs separately in _ws_host_origin_is_allowed(). """ 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 + + bound_host = getattr(app.state, "bound_host", "") or "" + bound_host = bound_host.strip().lower() + if not bound_host or bound_host in _LOOPBACK_HOSTS: + return False + + allowed_hosts = {bound_host} + try: + import socket + for info in socket.getaddrinfo(bound_host, None): + if info and info[4]: + allowed_hosts.add(str(info[4][0]).lower()) + except Exception: + pass + + return client_host.strip().lower() in allowed_hosts def _ws_host_origin_is_allowed(ws: "WebSocket") -> bool: diff --git a/tests/hermes_cli/test_web_server_host_header.py b/tests/hermes_cli/test_web_server_host_header.py index 9afef09d136d..58c550c94eab 100644 --- a/tests/hermes_cli/test_web_server_host_header.py +++ b/tests/hermes_cli/test_web_server_host_header.py @@ -215,3 +215,40 @@ def test_loopback_websocket_host_and_origin_are_accepted(self, monkeypatch): }, ): pass + + def test_non_loopback_client_matching_bound_host_is_accepted(self, monkeypatch): + """Dashboard WebSockets work when bound to an explicit tailnet host. + + Tailscale Serve can forward WebSocket upgrades from the same tailnet IP + that the dashboard was intentionally bound to. Keep the peer-IP guard + strict for loopback binds, but allow this explicit non-loopback bind + once Host/Origin validation has already matched the bound host. + """ + from types import SimpleNamespace + + import hermes_cli.web_server as ws + + monkeypatch.setattr(ws.app.state, "bound_host", "vps.tailnet.test", raising=False) + monkeypatch.setitem( + sys.modules, + "socket", + SimpleNamespace( + getaddrinfo=lambda host, port: [ + (0, 0, 0, "", ("100.64.0.10", 0)), + ], + ), + ) + + fake_ws = SimpleNamespace(client=SimpleNamespace(host="100.64.0.10")) + + assert ws._ws_client_is_allowed(fake_ws) # type: ignore[arg-type] + + def test_non_loopback_client_is_rejected_for_loopback_bind(self, monkeypatch): + from types import SimpleNamespace + + import hermes_cli.web_server as ws + + monkeypatch.setattr(ws.app.state, "bound_host", "127.0.0.1", raising=False) + fake_ws = SimpleNamespace(client=SimpleNamespace(host="100.64.0.10")) + + assert not ws._ws_client_is_allowed(fake_ws) # type: ignore[arg-type]