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
54 changes: 48 additions & 6 deletions hermes_cli/web_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,35 @@ def _require_token(request: Request) -> None:
})


def _dashboard_allowed_hosts() -> set[str]:
"""Extra trusted Host header values for loopback dashboards behind a proxy.

The dashboard normally accepts only loopback Host headers when it binds to
127.0.0.1. A trusted local reverse proxy can terminate HTTPS on an
external hostname and forward to 127.0.0.1 while preserving the original
Host header. Operators can opt in to those exact hostnames via
HERMES_DASHBOARD_ALLOWED_HOSTS without disabling the broader DNS-rebinding
protection.
"""
raw = os.getenv("HERMES_DASHBOARD_ALLOWED_HOSTS", "")
hosts: set[str] = set()
for item in raw.replace(";", ",").split(","):
value = item.strip().lower().rstrip(".")
if not value:
continue
if value.startswith("["):
close = value.find("]")
if close != -1:
value = value[1:close]
else:
value = value.strip("[]")
elif ":" in value:
value = value.rsplit(":", 1)[0]
if value:
hosts.add(value)
return hosts


def should_require_auth(host: str, allow_public: bool = False) -> bool:
"""Return True iff the dashboard auth gate must be active.

Expand Down Expand Up @@ -440,13 +469,27 @@ def _is_accepted_host(host_header: str, bound_host: str) -> bool:
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("[]")
if close == -1:
return False
host_only = h[1:close] # strip brackets
suffix = h[close + 1:]
if ":" not in host_only:
return False
if suffix and not (suffix.startswith(":") and suffix[1:].isdigit()):
return False
else:
host_only = h.rsplit(":", 1)[0] if ":" in h else h
host_only = host_only.lower()
host_only = host_only.lower().rstrip(".")
bound_lc = bound_host.lower().rstrip(".")

# Explicitly trusted proxy hostnames apply only to loopback binds. This is
# narrower than --insecure/0.0.0.0: only exact configured hostnames pass,
# and explicit non-loopback binds retain their exact-match behavior.
if (
bound_lc in _LOOPBACK_HOST_VALUES
and host_only in _dashboard_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
Expand All @@ -455,7 +498,6 @@ def _is_accepted_host(host_header: str, bound_host: str) -> bool:
return True

# Loopback bind: accept the loopback names
bound_lc = bound_host.lower()
if bound_lc in _LOOPBACK_HOST_VALUES:
return host_only in _LOOPBACK_HOST_VALUES

Expand Down
18 changes: 18 additions & 0 deletions tests/hermes_cli/test_web_server_host_header.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,24 @@ def test_case_insensitive_comparison(self):
assert _is_accepted_host("LOCALHOST", "127.0.0.1")
assert _is_accepted_host("LocalHost:9119", "127.0.0.1")

def test_loopback_bind_accepts_explicit_proxy_hostnames(self, monkeypatch):
"""Trusted reverse-proxy hostnames can be allowed without disabling
loopback DNS-rebinding protection for every other hostname."""
from hermes_cli.web_server import _is_accepted_host

monkeypatch.setenv(
"HERMES_DASHBOARD_ALLOWED_HOSTS",
"dashboard.example.test, secondary.example.test",
)

assert _is_accepted_host("dashboard.example.test", "127.0.0.1")
assert _is_accepted_host("dashboard.example.test:9443", "127.0.0.1")
assert _is_accepted_host("secondary.example.test.", "127.0.0.1")
assert not _is_accepted_host("evil.example", "127.0.0.1")
assert not _is_accepted_host("dashboard.example.test", "my-server.corp.net")
assert not _is_accepted_host("[dashboard.example.test]junk", "127.0.0.1")
assert not _is_accepted_host("[::1]:not-a-port", "127.0.0.1")


class TestHostHeaderMiddleware:
"""End-to-end test via the FastAPI app — verify the middleware
Expand Down
17 changes: 17 additions & 0 deletions website/docs/user-guide/features/web-dashboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,23 @@ hermes dashboard --host 0.0.0.0
hermes dashboard --no-open
```

### Trusted reverse-proxy hostnames

When the dashboard remains bound to loopback behind a trusted reverse proxy
that preserves the original `Host` header, allow the proxy's exact external
hostnames with a comma-separated environment variable:

```bash
HERMES_DASHBOARD_ALLOWED_HOSTS=dashboard.example.test hermes dashboard --no-open
```

This setting only extends Host-header validation for the listed names. It does
not bind the dashboard to a public interface or allow other hostnames. A
loopback dashboard does not add authentication for external clients: the
reverse proxy must terminate TLS, enforce authentication and access controls,
and must not expose the loopback upstream directly. Avoid `--insecure` when a
secured loopback reverse proxy is sufficient.

## Managing multiple profiles

The dashboard is a **machine-level** management surface: one server manages
Expand Down
Loading