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
21 changes: 21 additions & 0 deletions hermes_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -10353,6 +10353,9 @@ def cmd_dashboard(args):
open_browser=not args.no_open,
allow_public=getattr(args, "insecure", False),
embedded_chat=embedded_chat,
tls_cert=getattr(args, "tls_cert", None),
tls_key=getattr(args, "tls_key", None),
allowed_hosts=getattr(args, "allowed_host", None),
)


Expand Down Expand Up @@ -13166,6 +13169,24 @@ def cmd_acp(args):
action="store_true",
help="Allow binding to non-localhost (DANGEROUS: exposes API keys on the network)",
)
dashboard_parser.add_argument(
"--tls-cert",
dest="tls_cert",
help="Path to TLS certificate PEM file for serving the dashboard over HTTPS",
)
dashboard_parser.add_argument(
"--tls-key",
dest="tls_key",
help="Path to TLS private key PEM file for serving the dashboard over HTTPS",
)
dashboard_parser.add_argument(
"--allowed-host",
action="append",
help=(
"Additional allowed Host header value(s), comma-separated or repeatable. "
"Useful with --host 0.0.0.0 to restrict LAN dashboard access to named hosts."
),
)
dashboard_parser.add_argument(
"--tui",
action="store_true",
Expand Down
111 changes: 89 additions & 22 deletions hermes_cli/web_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
import urllib.parse
import urllib.request
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from typing import Any, Dict, Iterable, List, Optional, Tuple

import yaml

Expand Down Expand Up @@ -159,17 +159,10 @@ def _require_token(request: Request) -> None:
})


def _is_accepted_host(host_header: str, bound_host: str) -> bool:
"""True if the Host header targets the interface we bound to.

Accepts:
- Exact bound host (with or without port suffix)
- Loopback aliases when bound to loopback
- Any host when bound to 0.0.0.0 (explicit opt-in to non-loopback,
no protection possible at this layer)
"""
def _strip_host_port(host_header: str) -> str:
"""Return a normalized hostname from a Host header value."""
if not host_header:
return False
return ""
# Strip port suffix. IPv6 addresses use bracket notation:
# [::1] — no port
# [::1]:9119 — with port
Expand All @@ -178,21 +171,67 @@ def _is_accepted_host(host_header: str, bound_host: str) -> bool:
# 127.0.0.1:9119
h = host_header.strip()
if h.startswith("["):
# IPv6 bracketed — port (if any) follows "]:"
close = h.find("]")
if close != -1:
host_only = h[1:close] # strip brackets
else:
host_only = h.strip("[]")
else:
host_only = h.rsplit(":", 1)[0] if ":" in h else h
host_only = host_only.lower()
return host_only.lower()


def _normalize_allowed_hosts(allowed_hosts: Optional[Iterable[str] | str]) -> Optional[frozenset[str]]:
"""Normalize dashboard Host allowlist values.

``allowed_hosts`` may come from a comma-separated CLI flag or a caller-provided
iterable. Values may include ports; comparisons always use lowercase hostnames
only, matching Host header semantics.
"""
if allowed_hosts is None:
return None
if isinstance(allowed_hosts, str):
raw_hosts = allowed_hosts.split(",")
else:
raw_hosts = []
for item in allowed_hosts:
raw_hosts.extend(str(item).split(","))
normalized = frozenset(
host
for host in (_strip_host_port(str(item)) for item in raw_hosts)
if host
)
return normalized or None


def _is_accepted_host(
host_header: str,
bound_host: str,
*,
allowed_hosts: Optional[Iterable[str] | str] = None,
) -> bool:
"""True if the Host header targets an accepted dashboard hostname.

Accepts:
- Explicit allowed hosts when an allowlist is configured
- Exact bound host (with or without port suffix)
- Loopback aliases when bound to loopback
- Any host when bound to 0.0.0.0 and no allowlist is configured
"""
host_only = _strip_host_port(host_header)
if not host_only:
return False

normalized_allowed_hosts = _normalize_allowed_hosts(allowed_hosts)
if normalized_allowed_hosts is not None and host_only in normalized_allowed_hosts:
return True

# 0.0.0.0 bind means operator explicitly opted into all-interfaces
# (requires --insecure per web_server.start_server). No Host-layer
# defence can protect that mode; rely on operator network controls.
# (requires --insecure per web_server.start_server). Without an explicit
# allowed-host list, preserve the historical accept-any Host behavior. With
# an allowlist, fail closed for unlisted Host values.
if bound_host in {"0.0.0.0", "::"}:
return True
return normalized_allowed_hosts is None

# Loopback bind: accept the loopback names
bound_lc = bound_host.lower()
Expand Down Expand Up @@ -220,7 +259,8 @@ async def host_header_middleware(request: Request, call_next):
bound_host = getattr(app.state, "bound_host", None)
if bound_host:
host_header = request.headers.get("host", "")
if not _is_accepted_host(host_header, bound_host):
allowed_hosts = getattr(app.state, "allowed_hosts", None)
if not _is_accepted_host(host_header, bound_host, allowed_hosts=allowed_hosts):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This applies the new allowlist only to HTTP middleware. The PR’s /api/pty, /api/ws, /api/pub, and /api/events WebSocket endpoints do not call _is_accepted_host, so a WebSocket upgrade bypasses the stated Host restriction. Please thread the same normalized allowlist through WebSocket Host and Origin validation and add coverage.

return JSONResponse(
status_code=400,
content={
Expand Down Expand Up @@ -3365,7 +3405,7 @@ def _resolve_chat_argv(


def _build_sidecar_url(channel: str) -> Optional[str]:
"""ws:// URL the PTY child should publish events to, or None when unbound."""
"""WebSocket URL the PTY child should publish events to, or None when unbound."""
host = getattr(app.state, "bound_host", None)
port = getattr(app.state, "bound_port", None)

Expand All @@ -3374,8 +3414,9 @@ def _build_sidecar_url(channel: str) -> Optional[str]:

netloc = f"[{host}]:{port}" if ":" in host and not host.startswith("[") else f"{host}:{port}"
qs = urllib.parse.urlencode({"token": _SESSION_TOKEN, "channel": channel})
scheme = "wss" if getattr(app.state, "dashboard_tls_enabled", False) else "ws"

return f"ws://{netloc}/api/pub?{qs}"
return f"{scheme}://{netloc}/api/pub?{qs}"


async def _broadcast_event(channel: str, payload: str) -> None:
Expand Down Expand Up @@ -4518,6 +4559,9 @@ def start_server(
allow_public: bool = False,
*,
embedded_chat: bool = False,
tls_cert: Optional[str] = None,
tls_key: Optional[str] = None,
allowed_hosts: Optional[Iterable[str] | str] = None,
):
"""Start the web UI server."""
import uvicorn
Expand All @@ -4526,6 +4570,18 @@ def start_server(
_DASHBOARD_EMBEDDED_CHAT_ENABLED = embedded_chat

_LOCALHOST = ("127.0.0.1", "localhost", "::1")
if bool(tls_cert) != bool(tls_key):
raise SystemExit("Dashboard TLS requires both --tls-cert and --tls-key.")

ssl_certfile = str(Path(tls_cert).expanduser()) if tls_cert else None
ssl_keyfile = str(Path(tls_key).expanduser()) if tls_key else None
if ssl_certfile and not Path(ssl_certfile).is_file():
raise SystemExit(f"Dashboard TLS certificate not found: {ssl_certfile}")
if ssl_keyfile and not Path(ssl_keyfile).is_file():
raise SystemExit(f"Dashboard TLS key not found: {ssl_keyfile}")

normalized_allowed_hosts = _normalize_allowed_hosts(allowed_hosts)

if host not in _LOCALHOST and not allow_public:
raise SystemExit(
f"Refusing to bind to {host} — the dashboard exposes API keys "
Expand All @@ -4544,6 +4600,9 @@ def start_server(
# PTY child uses to publish events to the dashboard sidebar.
app.state.bound_host = host
app.state.bound_port = port
app.state.allowed_hosts = normalized_allowed_hosts
app.state.dashboard_tls_enabled = bool(ssl_certfile and ssl_keyfile)
scheme = "https" if app.state.dashboard_tls_enabled else "http"

if open_browser:
import webbrowser
Expand All @@ -4565,7 +4624,7 @@ def start_server(
def _open():
try:
time.sleep(1.0)
webbrowser.open(f"http://{host}:{port}")
webbrowser.open(f"{scheme}://{host}:{port}")
except Exception:
pass

Expand All @@ -4576,8 +4635,16 @@ def _open():
"(headless Linux). Pass --no-open to suppress this detection."
)

print(f" Hermes Web UI → http://{host}:{port}")
print(f" Hermes Web UI → {scheme}://{host}:{port}")
# proxy_headers=False so _ws_client_is_allowed sees the real connection peer
# rather than X-Forwarded-For's rewritten value (which would defeat the
# loopback gate when behind a reverse proxy).
uvicorn.run(app, host=host, port=port, log_level="warning", proxy_headers=False)
run_kwargs = {
"host": host,
"port": port,
"log_level": "warning",
"proxy_headers": False,
}
if ssl_certfile and ssl_keyfile:
run_kwargs.update({"ssl_certfile": ssl_certfile, "ssl_keyfile": ssl_keyfile})
uvicorn.run(app, **run_kwargs)
114 changes: 110 additions & 4 deletions tests/hermes_cli/test_web_server_host_header.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,16 +54,52 @@ def test_loopback_bind_rejects_attacker_hostnames(self):
f"bound={bound} must reject attacker host={attacker!r}"
)

def test_zero_zero_bind_accepts_anything(self):
"""0.0.0.0 means operator explicitly opted into all-interfaces
(requires --insecure). No Host-layer defence is possible — rely
on operator network controls."""
def test_zero_zero_bind_accepts_anything_without_allowlist(self):
"""0.0.0.0 without an allowlist preserves the historical explicit
all-interfaces opt-in behavior for operators who pass --insecure."""
from hermes_cli.web_server import _is_accepted_host

for host in ("10.0.0.5", "evil.example", "my-server.corp.net"):
assert _is_accepted_host(host, "0.0.0.0")
assert _is_accepted_host(host + ":9119", "0.0.0.0")

def test_zero_zero_bind_can_be_restricted_by_allowed_hosts(self):
"""LAN binds can still protect the app layer when operators provide
explicit allowed Host values."""
from hermes_cli.web_server import _is_accepted_host

allowed_hosts = {
"hermes-dashboard.transformers.lan",
"10.0.0.196",
"localhost",
}

assert _is_accepted_host(
"hermes-dashboard.transformers.lan:9443",
"0.0.0.0",
allowed_hosts=allowed_hosts,
)
assert _is_accepted_host(
"10.0.0.196:9443",
"0.0.0.0",
allowed_hosts=allowed_hosts,
)
assert not _is_accepted_host(
"evil.example:9443",
"0.0.0.0",
allowed_hosts=allowed_hosts,
)
assert _is_accepted_host(
"localhost:9119",
"127.0.0.1",
allowed_hosts={"hermes-dashboard.transformers.lan"},
)
assert _is_accepted_host(
"hermes-dashboard.transformers.lan:9443",
"127.0.0.1",
allowed_hosts={"hermes-dashboard.transformers.lan"},
)

def test_explicit_non_loopback_bind_requires_exact_match(self):
"""If the operator bound to a specific non-loopback hostname,
the Host header must match exactly."""
Expand Down Expand Up @@ -146,3 +182,73 @@ def test_no_bound_host_skips_validation(self):
resp = client.get("/api/status")
# Should get through to the status endpoint, not a 400
assert resp.status_code != 400


class TestDashboardServerStartup:
def test_start_server_passes_tls_files_to_uvicorn(self, tmp_path, monkeypatch):
import uvicorn
from hermes_cli.web_server import app, start_server

cert = tmp_path / "dashboard.crt"
key = tmp_path / "dashboard.key"
cert.write_text("test cert")
key.write_text("test key")
calls = []

def fake_run(*args, **kwargs):
calls.append((args, kwargs))

monkeypatch.setattr(uvicorn, "run", fake_run)

start_server(
host="0.0.0.0",
port=9443,
open_browser=False,
allow_public=True,
tls_cert=str(cert),
tls_key=str(key),
allowed_hosts={"hermes-dashboard.transformers.lan", "10.0.0.196"},
)

assert calls == [
(
(app,),
{
"host": "0.0.0.0",
"port": 9443,
"log_level": "warning",
"proxy_headers": False,
"ssl_certfile": str(cert),
"ssl_keyfile": str(key),
},
)
]
assert app.state.allowed_hosts == {
"hermes-dashboard.transformers.lan",
"10.0.0.196",
}

def test_start_server_rejects_partial_tls_configuration(self, tmp_path):
from hermes_cli.web_server import start_server

cert = tmp_path / "dashboard.crt"
cert.write_text("test cert")

with pytest.raises(SystemExit, match="both --tls-cert and --tls-key"):
start_server(
host="127.0.0.1",
port=9443,
open_browser=False,
tls_cert=str(cert),
)

def test_tls_dashboard_uses_wss_sidecar_url(self, monkeypatch):
from hermes_cli.web_server import _build_sidecar_url, app

monkeypatch.setattr(app.state, "bound_host", "127.0.0.1", raising=False)
monkeypatch.setattr(app.state, "bound_port", 9443, raising=False)
monkeypatch.setattr(app.state, "dashboard_tls_enabled", True, raising=False)

url = _build_sidecar_url("abc-123")
assert url is not None
assert url.startswith("wss://127.0.0.1:9443/api/pub?")
10 changes: 10 additions & 0 deletions website/docs/reference/cli-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -1154,6 +1154,9 @@ Launch the web dashboard — a browser-based UI for managing configuration, API
| `--no-open` | — | Don't auto-open the browser |
| `--tui` | off | Enable the in-browser Chat tab by running `hermes --tui` behind a PTY/WebSocket bridge. Requires `pip install 'hermes-agent[web,pty]'` and a POSIX PTY environment such as Linux, macOS, or WSL2. |
| `--insecure` | off | Allow binding to non-localhost hosts. Exposes dashboard credentials on the network; use only behind trusted network controls. |
| `--tls-cert` | — | Path to TLS certificate PEM file for serving the dashboard over HTTPS; requires `--tls-key`. |
| `--tls-key` | — | Path to TLS private key PEM file for serving the dashboard over HTTPS; requires `--tls-cert`. |
| `--allowed-host` | — | Additional accepted Host header value. Repeat the flag or pass comma-separated values; useful with `--host 0.0.0.0` to fail closed for unlisted LAN hostnames. |
| `--stop` | — | Stop running `hermes dashboard` processes and exit. |
| `--status` | — | List running `hermes dashboard` processes and exit. |

Expand All @@ -1164,6 +1167,13 @@ hermes dashboard
# Custom port, no browser
hermes dashboard --port 8080 --no-open

# Serve HTTPS directly on a trusted LAN hostname
hermes dashboard \
--host 0.0.0.0 --insecure \
--tls-cert /path/to/dashboard.crt \
--tls-key /path/to/dashboard.key \
--allowed-host hermes-dashboard.example.lan

# Enable the browser Chat tab
hermes dashboard --tui
```
Expand Down
Loading