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
6 changes: 4 additions & 2 deletions hermes_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -1523,8 +1523,10 @@ def _ensure_hermes_home_managed(home: Path):
# Public URL override (env: ``HERMES_DASHBOARD_PUBLIC_URL``).
# When set, this is the complete authority — scheme + host +
# optional path prefix (e.g. ``https://example.com/hermes``) —
# the OAuth ``redirect_uri`` is built from. Set this for deploys
# behind reverse proxies that don't reliably forward
# the OAuth ``redirect_uri`` is built from, and the host is accepted
# by the dashboard Host/Origin guard for trusted reverse-proxy
# deployments that preserve the browser-facing Host header. Set this
# for deploys behind reverse proxies that don't reliably forward
# ``X-Forwarded-Host`` / ``X-Forwarded-Proto`` / ``X-Forwarded-Prefix``
# (manual nginx setups, on-prem ingresses, custom-domain Fly
# deploys without proper proxy headers). When set,
Expand Down
85 changes: 66 additions & 19 deletions hermes_cli/web_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,63 @@ def _require_token(request: Request) -> None:
})


def _host_header_hostname(host_header: str) -> str:
"""Return a normalized hostname from an incoming Host header.

This parser is intentionally stricter than URL parsing: actual Host
headers are authorities, not full URLs. Malformed values fail closed so
the DNS-rebinding guard never turns a bad Host header into localhost.
"""
h = (host_header or "").strip()
if not h:
return ""
if any(c in h for c in ('"', "'", "<", ">", " ", "\n", "\r", "\t")):
return ""
if "://" in h or any(c in h for c in ("/", "?", "#", "@")):
return ""
if h.startswith("["):
# IPv6 bracketed — port (if any) follows "]:"
close = h.find("]")
if close == -1:
return ""
host_only = h[1:close]
if ":" not in host_only:
return ""
rest = h[close + 1:]
if rest and not re.fullmatch(r":\d+", rest):
return ""
return host_only.lower()
if h.count(":") > 1:
# IPv6 Host headers should use bracket notation, e.g. [::1]:9119.
return ""
if ":" in h:
host_only, port = h.rsplit(":", 1)
if not host_only or not port.isdigit():
return ""
return host_only.lower()
return h.lower()


def _dashboard_public_hostname() -> str:
"""Return the operator-declared browser-facing dashboard hostname.

``dashboard.public_url`` / ``HERMES_DASHBOARD_PUBLIC_URL`` is already the
canonical reverse-proxy URL for OAuth redirects. Reusing its hostname here
keeps the Host/Origin guard aligned with the public URL contract instead
of introducing a second allowlist setting.
"""
try:
from hermes_cli.dashboard_auth.prefix import resolve_public_url

public_url = resolve_public_url()
if not public_url:
return ""
parsed = urllib.parse.urlparse(public_url)
except Exception: # noqa: BLE001 - malformed config must fail closed
return ""
return (parsed.hostname or "").lower()


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

Expand All @@ -279,33 +336,23 @@ def should_require_auth(host: str, allow_public: bool) -> bool:


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

Accepts:
- Exact bound host (with or without port suffix)
- Loopback aliases when bound to loopback
- The exact hostname from dashboard.public_url / HERMES_DASHBOARD_PUBLIC_URL
for trusted reverse-proxy deployments
- Any host when bound to 0.0.0.0 (explicit opt-in to non-loopback,
no protection possible at this layer)
"""
if not host_header:
host_only = _host_header_hostname(host_header)
if not host_only:
return False
# Strip port suffix. IPv6 addresses use bracket notation:
# [::1] — no port
# [::1]:9119 — with port
# Plain hosts/v4:
# localhost:9119
# 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()

public_host = _dashboard_public_hostname()
if public_host and host_only == public_host:
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 Down
145 changes: 145 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,59 @@ 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_malformed_host_header_is_rejected(self):
"""Actual Host headers are authorities, not full URLs."""
from hermes_cli.web_server import _is_accepted_host

assert not _is_accepted_host("http://localhost:9119", "127.0.0.1")
assert not _is_accepted_host("http://[::1", "127.0.0.1")
assert not _is_accepted_host("localhost:", "127.0.0.1")
assert not _is_accepted_host("localhost:notaport", "127.0.0.1")
assert not _is_accepted_host("[localhost]", "127.0.0.1")

def test_loopback_bind_accepts_public_url_host(self, monkeypatch):
"""Trusted reverse-proxy hostnames come from dashboard.public_url.

This is the deployment shape for Tailscale Serve: the dashboard remains
bound to loopback, while the browser-facing Host header is the tailnet
DNS name. The accepted host must be exact so DNS-rebinding attacker
hosts still fail closed.
"""
from hermes_cli.web_server import _is_accepted_host

monkeypatch.setenv(
"HERMES_DASHBOARD_PUBLIC_URL",
"https://dashboard.tailnet.example.ts.net/hermes",
)

assert _is_accepted_host("dashboard.tailnet.example.ts.net", "127.0.0.1")
assert _is_accepted_host("dashboard.tailnet.example.ts.net:443", "127.0.0.1")
assert _is_accepted_host("dashboard.tailnet.example.ts.net", "localhost")
assert not _is_accepted_host("evil.example", "127.0.0.1")
assert not _is_accepted_host(
"dashboard.tailnet.example.ts.net.evil.example",
"127.0.0.1",
)
assert not _is_accepted_host(
"dashboard.tailnet.example.ts.net:443.evil",
"127.0.0.1",
)

def test_loopback_bind_accepts_config_public_url_host(self, monkeypatch):
"""dashboard.public_url works when the env override is unset."""
from hermes_cli.dashboard_auth import prefix
from hermes_cli.web_server import _is_accepted_host

monkeypatch.delenv("HERMES_DASHBOARD_PUBLIC_URL", raising=False)
monkeypatch.setattr(
prefix,
"_load_dashboard_section",
lambda: {"public_url": "https://from-config.example/hermes"},
)

assert _is_accepted_host("from-config.example", "127.0.0.1")
assert not _is_accepted_host("from-config.example.evil.example", "127.0.0.1")


class TestHostHeaderMiddleware:
"""End-to-end test via the FastAPI app — verify the middleware
Expand Down Expand Up @@ -131,6 +184,48 @@ def test_legit_loopback_request_accepted(self):
if hasattr(app.state, "bound_host"):
del app.state.bound_host

def test_public_url_host_request_accepted(self, monkeypatch):
from fastapi.testclient import TestClient
from hermes_cli.web_server import app

monkeypatch.setenv(
"HERMES_DASHBOARD_PUBLIC_URL",
"https://dashboard.tailnet.example.ts.net/hermes",
)
app.state.bound_host = "127.0.0.1"
try:
client = TestClient(app)
resp = client.get(
"/api/status",
headers={"Host": "dashboard.tailnet.example.ts.net"},
)
assert resp.status_code != 400
assert "Invalid Host header" not in resp.text
finally:
if hasattr(app.state, "bound_host"):
del app.state.bound_host

def test_public_url_host_suffix_request_rejected(self, monkeypatch):
from fastapi.testclient import TestClient
from hermes_cli.web_server import app

monkeypatch.setenv(
"HERMES_DASHBOARD_PUBLIC_URL",
"https://dashboard.tailnet.example.ts.net/hermes",
)
app.state.bound_host = "127.0.0.1"
try:
client = TestClient(app)
resp = client.get(
"/api/status",
headers={"Host": "dashboard.tailnet.example.ts.net.evil.example"},
)
assert resp.status_code == 400
assert "Invalid Host header" in resp.json()["detail"]
finally:
if hasattr(app.state, "bound_host"):
del app.state.bound_host

def test_no_bound_host_skips_validation(self):
"""If app.state.bound_host isn't set (e.g. running under test
infra without calling start_server), middleware must pass through
Expand Down Expand Up @@ -215,3 +310,53 @@ def test_loopback_websocket_host_and_origin_are_accepted(self, monkeypatch):
},
):
pass

def test_public_url_websocket_host_and_origin_are_accepted(self, monkeypatch):
from fastapi.testclient import TestClient

import hermes_cli.web_server as ws

monkeypatch.setenv(
"HERMES_DASHBOARD_PUBLIC_URL",
"https://dashboard.tailnet.example.ts.net/hermes",
)
monkeypatch.setattr(ws.app.state, "bound_host", "127.0.0.1", raising=False)
monkeypatch.setattr(ws, "_DASHBOARD_EMBEDDED_CHAT_ENABLED", True)

client = TestClient(ws.app)
url = f"/api/events?token={ws._SESSION_TOKEN}&channel=security-test"
with client.websocket_connect(
url,
headers={
"Host": "dashboard.tailnet.example.ts.net",
"Origin": "https://dashboard.tailnet.example.ts.net",
},
):
pass

def test_public_url_websocket_cross_site_origin_is_rejected(self, monkeypatch):
from fastapi.testclient import TestClient
from starlette.websockets import WebSocketDisconnect

import hermes_cli.web_server as ws

monkeypatch.setenv(
"HERMES_DASHBOARD_PUBLIC_URL",
"https://dashboard.tailnet.example.ts.net/hermes",
)
monkeypatch.setattr(ws.app.state, "bound_host", "127.0.0.1", raising=False)
monkeypatch.setattr(ws, "_DASHBOARD_EMBEDDED_CHAT_ENABLED", True)

client = TestClient(ws.app)
url = f"/api/events?token={ws._SESSION_TOKEN}&channel=security-test"
with pytest.raises(WebSocketDisconnect) as exc:
with client.websocket_connect(
url,
headers={
"Host": "dashboard.tailnet.example.ts.net",
"Origin": "https://evil.example",
},
):
pass

assert exc.value.code == 4403