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
24 changes: 22 additions & 2 deletions hermes_cli/web_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
37 changes: 37 additions & 0 deletions tests/hermes_cli/test_web_server_host_header.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]