hermes - 💡(How to fix) Fix [Feature] dashboard: add --allowed-hosts flag for reverse-proxy and Tailscale access

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…

The hermes dashboard command binds to 127.0.0.1:9119 by default and enforces a host-header validation middleware (defence against DNS rebinding, GHSA-ppp5-vxwm-4cf7). When the dashboard is placed behind a reverse proxy — including Tailscale Serve, nginx, Caddy, or any similar TLS terminator — the proxy forwards the original Host header from the client (e.g. kevins-mini.tail79ec1f.ts.net) to the local server. Because that hostname doesn't match the loopback allowlist, every request is rejected with:

{"detail": "Invalid Host header. Dashboard requests must use the hostname the server was bound to."}

This makes it impossible to use the dashboard remotely via Tailscale Serve (the recommended way to expose local services on a tailnet with automatic TLS) or any other reverse proxy, without resorting to --insecure --host 0.0.0.0 which exposes the dashboard on all interfaces.

Root Cause

The hermes dashboard command binds to 127.0.0.1:9119 by default and enforces a host-header validation middleware (defence against DNS rebinding, GHSA-ppp5-vxwm-4cf7). When the dashboard is placed behind a reverse proxy — including Tailscale Serve, nginx, Caddy, or any similar TLS terminator — the proxy forwards the original Host header from the client (e.g. kevins-mini.tail79ec1f.ts.net) to the local server. Because that hostname doesn't match the loopback allowlist, every request is rejected with:

Fix Action

Fix / Workaround

  • --host 0.0.0.0 --insecure: Works but exposes the dashboard on all interfaces (LAN, WiFi, etc.), not just the Tailscale interface. Unnecessary risk.
  • Binding to the Tailscale IP directly (--host 100.x.x.x): Limits exposure to the Tailscale interface, but the exact-match host check still rejects the MagicDNS hostname. Requires --allowed-hosts anyway.
  • No code change, document workaround: Leaves users with no good option for reverse-proxy setups without --insecure.

Code Example

{"detail": "Invalid Host header. Dashboard requests must use the hostname the server was bound to."}

---

tailscale serve --bg --https=443 9119

---

def _is_accepted_host(
    host_header: str,
    bound_host: str,
    extra_allowed: Optional[frozenset] = None,
) -> bool:
    # ... existing port-stripping logic unchanged ...

    if bound_host in {"0.0.0.0", "::"}:
        return True

    # Check explicitly allowed hostnames before the bound-host rule.
    # Enables reverse-proxy / Tailscale Serve access without --insecure.
    if extra_allowed and host_only in extra_allowed:
        return True

    bound_lc = bound_host.lower()
    if bound_lc in _LOOPBACK_HOST_VALUES:
        return host_only in _LOOPBACK_HOST_VALUES

    return host_only == bound_lc

---

bound_host = getattr(app.state, "bound_host", None)
if bound_host:
    host_header = request.headers.get("host", "")
    extra_allowed = getattr(app.state, "extra_allowed_hosts", None)
    if not _is_accepted_host(host_header, bound_host, extra_allowed):
        return JSONResponse(status_code=400, content={"detail": "..."})

---

extra_allowed = getattr(app.state, "extra_allowed_hosts", None)
if not _is_accepted_host(host_header, bound_host, extra_allowed):
    return False
# ...
return _is_accepted_host(parsed.netloc, bound_host, extra_allowed)

---

def start_server(
    host: str = "127.0.0.1",
    port: int = 9119,
    open_browser: bool = True,
    allow_public: bool = False,
    *,
    embedded_chat: bool = False,
    allowed_hosts: Optional[List[str]] = None,
):
    # ...
    app.state.bound_host = host
    app.state.bound_port = port
    app.state.extra_allowed_hosts = (
        frozenset(h.lower().strip() for h in allowed_hosts if h.strip())
        if allowed_hosts
        else None
    )

---

dashboard_parser.add_argument(
    "--allowed-hosts",
    dest="allowed_hosts",
    default="",
    help=(
        "Comma-separated list of additional hostnames to accept in the Host header "
        "(e.g. your Tailscale MagicDNS name). Use with --host <ip> --insecure "
        "to expose the dashboard only on a specific interface."
    ),
)

---

allowed_hosts_raw = getattr(args, "allowed_hosts", "") or ""
allowed_hosts = [h for h in (h.strip() for h in allowed_hosts_raw.split(",")) if h]
start_server(
    host=args.host,
    port=args.port,
    open_browser=not args.no_open,
    allow_public=getattr(args, "insecure", False),
    embedded_chat=embedded_chat,
    allowed_hosts=allowed_hosts or None,
)

---

# One-time Tailscale Serve setup (if not already configured):
tailscale serve --bg --https=443 9119

# Start dashboard — no --insecure needed, server stays on 127.0.0.1
hermes dashboard --allowed-hosts your-machine.tail12345.ts.net --no-open

---

hermes dashboard --host 192.168.1.x --allowed-hosts dashboard.internal.example.com --insecure
RAW_BUFFERClick to expand / collapse

Summary

The hermes dashboard command binds to 127.0.0.1:9119 by default and enforces a host-header validation middleware (defence against DNS rebinding, GHSA-ppp5-vxwm-4cf7). When the dashboard is placed behind a reverse proxy — including Tailscale Serve, nginx, Caddy, or any similar TLS terminator — the proxy forwards the original Host header from the client (e.g. kevins-mini.tail79ec1f.ts.net) to the local server. Because that hostname doesn't match the loopback allowlist, every request is rejected with:

{"detail": "Invalid Host header. Dashboard requests must use the hostname the server was bound to."}

This makes it impossible to use the dashboard remotely via Tailscale Serve (the recommended way to expose local services on a tailnet with automatic TLS) or any other reverse proxy, without resorting to --insecure --host 0.0.0.0 which exposes the dashboard on all interfaces.

Use Case

Tailscale Serve is a common pattern for securely exposing local services to a private tailnet:

tailscale serve --bg --https=443 9119

This proxies https://<machine>.tail<id>.ts.nethttp://127.0.0.1:9119. Tailscale handles TLS automatically. The dashboard stays loopback-bound (never exposed to the LAN), but the host header the local server sees is the MagicDNS hostname, not 127.0.0.1.

The same pattern applies to any TLS-terminating reverse proxy (nginx proxy_pass, Caddy reverse_proxy, Traefik, etc.).

Proposed Fix

Add an --allowed-hosts CLI flag that accepts a comma-separated list of additional hostnames to whitelist in the host-header middleware. The DNS rebinding protection is fully preserved — only explicitly listed names are added to the allowlist; no wildcard or bypass logic is introduced.

hermes_cli/web_server.py

1. _is_accepted_host — add extra_allowed parameter:

def _is_accepted_host(
    host_header: str,
    bound_host: str,
    extra_allowed: Optional[frozenset] = None,
) -> bool:
    # ... existing port-stripping logic unchanged ...

    if bound_host in {"0.0.0.0", "::"}:
        return True

    # Check explicitly allowed hostnames before the bound-host rule.
    # Enables reverse-proxy / Tailscale Serve access without --insecure.
    if extra_allowed and host_only in extra_allowed:
        return True

    bound_lc = bound_host.lower()
    if bound_lc in _LOOPBACK_HOST_VALUES:
        return host_only in _LOOPBACK_HOST_VALUES

    return host_only == bound_lc

2. host_header_middleware — read app.state.extra_allowed_hosts:

bound_host = getattr(app.state, "bound_host", None)
if bound_host:
    host_header = request.headers.get("host", "")
    extra_allowed = getattr(app.state, "extra_allowed_hosts", None)
    if not _is_accepted_host(host_header, bound_host, extra_allowed):
        return JSONResponse(status_code=400, content={"detail": "..."})

3. _ws_host_origin_is_allowed — same for WebSocket upgrades:

extra_allowed = getattr(app.state, "extra_allowed_hosts", None)
if not _is_accepted_host(host_header, bound_host, extra_allowed):
    return False
# ...
return _is_accepted_host(parsed.netloc, bound_host, extra_allowed)

4. start_server — new allowed_hosts keyword argument:

def start_server(
    host: str = "127.0.0.1",
    port: int = 9119,
    open_browser: bool = True,
    allow_public: bool = False,
    *,
    embedded_chat: bool = False,
    allowed_hosts: Optional[List[str]] = None,
):
    # ...
    app.state.bound_host = host
    app.state.bound_port = port
    app.state.extra_allowed_hosts = (
        frozenset(h.lower().strip() for h in allowed_hosts if h.strip())
        if allowed_hosts
        else None
    )

hermes_cli/main.py

Add --allowed-hosts to the dashboard argument parser:

dashboard_parser.add_argument(
    "--allowed-hosts",
    dest="allowed_hosts",
    default="",
    help=(
        "Comma-separated list of additional hostnames to accept in the Host header "
        "(e.g. your Tailscale MagicDNS name). Use with --host <ip> --insecure "
        "to expose the dashboard only on a specific interface."
    ),
)

Pass it through in the cmd_dashboard handler:

allowed_hosts_raw = getattr(args, "allowed_hosts", "") or ""
allowed_hosts = [h for h in (h.strip() for h in allowed_hosts_raw.split(",")) if h]
start_server(
    host=args.host,
    port=args.port,
    open_browser=not args.no_open,
    allow_public=getattr(args, "insecure", False),
    embedded_chat=embedded_chat,
    allowed_hosts=allowed_hosts or None,
)

Usage After Fix

Tailscale Serve (recommended — stays loopback-bound, automatic TLS):

# One-time Tailscale Serve setup (if not already configured):
tailscale serve --bg --https=443 9119

# Start dashboard — no --insecure needed, server stays on 127.0.0.1
hermes dashboard --allowed-hosts your-machine.tail12345.ts.net --no-open

Explicit non-loopback bind (for nginx/Caddy on LAN):

hermes dashboard --host 192.168.1.x --allowed-hosts dashboard.internal.example.com --insecure

Security Considerations

  • The existing DNS rebinding protection (GHSA-ppp5-vxwm-4cf7) is fully preserved. extra_allowed is an explicit opt-in list with no wildcard matching.
  • When used with Tailscale Serve, the server remains bound to 127.0.0.1 — it is never exposed on any network interface. --insecure is not required.
  • Operators are responsible for ensuring listed hostnames are under their control. The flag is intentionally not accepting wildcards or regex patterns.

Alternatives Considered

  • --host 0.0.0.0 --insecure: Works but exposes the dashboard on all interfaces (LAN, WiFi, etc.), not just the Tailscale interface. Unnecessary risk.
  • Binding to the Tailscale IP directly (--host 100.x.x.x): Limits exposure to the Tailscale interface, but the exact-match host check still rejects the MagicDNS hostname. Requires --allowed-hosts anyway.
  • No code change, document workaround: Leaves users with no good option for reverse-proxy setups without --insecure.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING

hermes - 💡(How to fix) Fix [Feature] dashboard: add --allowed-hosts flag for reverse-proxy and Tailscale access