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
65 changes: 61 additions & 4 deletions api/dashboard_probe.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@

The official `hermes dashboard` binds to 127.0.0.1:9119 by default and exposes
GET /api/status as a public, read-only identity/status endpoint. Keep all
probing server-side to avoid browser CORS/mixed-content failures, and only allow
probing server-side to avoid browser CORS/mixed-content failures, and only probe
loopback targets so a user-controlled setting cannot become an SSRF primitive.
External dashboard URLs are allowed only as browser-facing links.
"""

from __future__ import annotations
Expand Down Expand Up @@ -61,6 +62,43 @@ def normalize_dashboard_url(raw_url: str | None) -> tuple[str, int, str, str] |
return normalized_host, port, parsed.scheme, base


def normalize_dashboard_link_url(raw_url: str | None) -> tuple[str, int, str, str, bool] | None:
"""Return a normalized dashboard browser link URL.

Unlike normalize_dashboard_url(), this accepts non-loopback hosts because
the returned URL is only opened by the user's browser. Server-side probes
still call probe_official_dashboard(), which rejects non-loopback hosts.
"""
raw = str(raw_url or "").strip()
if not raw:
return None
parsed = urlparse(raw)
if parsed.scheme not in {"http", "https"}:
raise ValueError("invalid dashboard URL scheme")
if parsed.username or parsed.password:
raise ValueError("invalid dashboard URL credentials")
host = parsed.hostname or ""
normalized_host = host.strip().lower()
if not normalized_host:
raise ValueError("invalid dashboard URL host")
try:
port = parsed.port
except ValueError as exc:
raise ValueError("invalid dashboard URL port") from exc
if port is None:
port = 443 if parsed.scheme == "https" else 80
if not (1 <= int(port) <= 65535):
raise ValueError("invalid dashboard URL port")
path = parsed.path or ""
if path not in ("", "/") or parsed.params or parsed.query or parsed.fragment:
raise ValueError("invalid dashboard URL path")
display_host = f"[{normalized_host}]" if ":" in normalized_host and not normalized_host.startswith("[") else normalized_host
default_port = 443 if parsed.scheme == "https" else 80
port_part = "" if port == default_port and parsed.port is None else f":{port}"
base = f"{parsed.scheme}://{display_host}{port_part}"
return normalized_host, int(port), parsed.scheme, base, normalized_host in _LOOPBACK_HOSTS


def _looks_like_official_dashboard(payload: object) -> bool:
if not isinstance(payload, dict):
return False
Expand Down Expand Up @@ -133,7 +171,7 @@ def get_dashboard_config(config_data: dict | None = None) -> dict:
raw_url = str(dashboard_cfg.get("url") or "").strip()
if raw_url:
# Normalize before echoing so the UI never displays unsafe/stale values.
_host, _port, _scheme, raw_url = normalize_dashboard_url(raw_url)
_host, _port, _scheme, raw_url, _is_loopback = normalize_dashboard_link_url(raw_url)
return {"enabled": enabled, "url": raw_url}


Expand All @@ -145,7 +183,7 @@ def save_dashboard_config(payload: dict) -> dict:
raw_url = str((payload or {}).get("url", "") or "").strip()
normalized_url = ""
if raw_url:
_host, _port, _scheme, normalized_url = normalize_dashboard_url(raw_url)
_host, _port, _scheme, normalized_url, _is_loopback = normalize_dashboard_link_url(raw_url)

from api import config as webui_config

Expand Down Expand Up @@ -186,10 +224,29 @@ def get_dashboard_status(config_data: dict | None = None) -> dict:

raw_url = dashboard_cfg.get("url") or dashboard_cfg.get("target") or ""
try:
override = normalize_dashboard_url(raw_url)
link_override = normalize_dashboard_link_url(raw_url)
except ValueError:
return {"running": False, "enabled": enabled, "error": "invalid dashboard url"}

if link_override:
host, port, scheme, base, is_loopback = link_override
if not is_loopback:
if enabled == "always":
return {"running": True, "enabled": enabled, "host": host, "port": port, "url": base, "external": True}
if not _webui_bind_host_allows_auto_probe():
return {"running": False, "enabled": enabled}
for probe_host, probe_port in DEFAULT_DASHBOARD_TARGETS:
result = probe_official_dashboard(probe_host, probe_port, timeout=DEFAULT_DASHBOARD_TIMEOUT, scheme="http")
if result.get("running"):
response = {"running": True, "enabled": enabled, "host": host, "port": port, "url": base, "external": True}
if result.get("version"):
response["version"] = result["version"]
return response
return {"running": False, "enabled": enabled}
override = (host, port, scheme, base)
else:
override = None

targets: list[tuple[str, int, str, str]]
if override:
targets = [override]
Expand Down
2 changes: 1 addition & 1 deletion static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -1209,7 +1209,7 @@ <h2 data-i18n="empty_title">What can I help with?</h2>
<button class="sm-btn" id="btnSignOut" onclick="signOut()" style="margin-top:6px;width:100%;padding:8px;font-weight:600;color:var(--accent);border-color:rgba(233,69,96,.3);display:none" data-i18n="sign_out">Sign Out</button>
<div class="settings-field" style="margin-top:18px;padding-top:16px;border-top:1px solid var(--border)">
<label for="settingsDashboardMode">Official Hermes Dashboard</label>
<div style="font-size:11px;color:var(--muted);margin-bottom:8px">Show a nav-rail link when the official <code>hermes dashboard</code> is reachable. Overrides are restricted to loopback URLs.</div>
<div style="font-size:11px;color:var(--muted);margin-bottom:8px">Show a nav-rail link when the official <code>hermes dashboard</code> is reachable. Loopback URLs are probed server-side; external URLs are used only as browser links.</div>
<select id="settingsDashboardMode" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px">
<option value="auto">Auto-detect</option>
<option value="always">Always show</option>
Expand Down
8 changes: 6 additions & 2 deletions static/ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -396,18 +396,22 @@ function _dashboardIsBrowserLoopback(){
return host==='127.0.0.1'||host==='localhost'||host==='::1';
}
function _dashboardBrowserUrl(status){
if(!status||!status.running||!status.port) return '';
if(!status||!status.running) return '';
if(status.external&&status.url) return status.url;
if(!status.port) return status.url||'';
let source;
try{source=new URL(status.url||('http://127.0.0.1:'+status.port));}
catch(_){source=new URL('http://127.0.0.1:'+status.port);}
const sourceHost=(source.hostname||'').replace(/^\[|\]$/g,'').toLowerCase();
if(status.url&&sourceHost&&sourceHost!=='127.0.0.1'&&sourceHost!=='localhost'&&sourceHost!=='::1') return status.url;
const browserHost=window.location.hostname||source.hostname;
const displayHost=browserHost.includes(':')&&!browserHost.startsWith('[')?'['+browserHost+']':browserHost;
return source.protocol+'//'+displayHost+':'+status.port;
}
function _applyDashboardStatus(status){
const running=!!(status&&status.running);
const url=running?_dashboardBrowserUrl(status):'';
const warning=running&&!_dashboardIsBrowserLoopback()?t('dashboard_loopback_warning'):'';
const warning=running&&!status.external&&!_dashboardIsBrowserLoopback()?t('dashboard_loopback_warning'):'';
document.querySelectorAll('[data-dashboard-link]').forEach(btn=>{
btn.classList.toggle('dashboard-link-visible',running);
btn.style.display=running?'':'none';
Expand Down
1 change: 1 addition & 0 deletions tests/test_dashboard_link_ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ def test_dashboard_frontend_opens_external_tab_safely_and_derives_browser_host_u
assert "noopener,noreferrer" in UI_JS
assert "window.location.hostname" in UI_JS
assert "_dashboardBrowserUrl" in UI_JS
assert "status.external&&status.url" in UI_JS
assert 'id="dashboardRailBtn"' in INDEX_HTML
assert re.search(r'id="dashboardRailBtn"[^>]*onclick="openHermesDashboard\(event\)"', INDEX_HTML)

Expand Down
96 changes: 87 additions & 9 deletions tests/test_dashboard_probe.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,51 @@ def test_dashboard_target_validation_allows_only_loopback_base_urls():
raise AssertionError(f"unsafe dashboard override accepted: {bad}")


def test_dashboard_link_validation_accepts_external_browser_links():
from api.dashboard_probe import normalize_dashboard_link_url, normalize_dashboard_url

assert normalize_dashboard_link_url("https://dashboard.example.com") == (
"dashboard.example.com",
443,
"https",
"https://dashboard.example.com",
False,
)
assert normalize_dashboard_link_url("http://ai.lan:9119") == (
"ai.lan",
9119,
"http",
"http://ai.lan:9119",
False,
)
assert normalize_dashboard_link_url("http://127.0.0.1:9119") == (
"127.0.0.1",
9119,
"http",
"http://127.0.0.1:9119",
True,
)

try:
normalize_dashboard_url("https://dashboard.example.com")
except ValueError:
pass
else:
raise AssertionError("external browser link URL must not become a probe URL")

for bad in (
"https://dashboard.example.com/path",
"https://user:pass@dashboard.example.com",
"file:///etc/passwd",
):
try:
normalize_dashboard_link_url(bad)
except ValueError:
pass
else:
raise AssertionError(f"unsafe dashboard browser link accepted: {bad}")


def test_status_tries_default_loopback_targets_until_dashboard_found(monkeypatch):
from api import dashboard_probe

Expand Down Expand Up @@ -144,9 +189,46 @@ def fail_probe(*args, **kwargs):
"enabled": "never",
}

result = dashboard_probe.get_dashboard_status(config_data={"webui": {"dashboard": {"url": "http://example.com:9119"}}})
assert result["running"] is False
assert "invalid" in result["error"]
result = dashboard_probe.get_dashboard_status(
config_data={"webui": {"dashboard": {"enabled": "always", "url": "https://dashboard.example.com"}}}
)
assert result == {
"running": True,
"enabled": "always",
"host": "dashboard.example.com",
"port": 443,
"url": "https://dashboard.example.com",
"external": True,
}


def test_status_external_link_still_probes_loopback_in_auto_mode(monkeypatch):
from api import dashboard_probe

monkeypatch.delenv("HERMES_WEBUI_HOST", raising=False)
attempts = []

def fake_probe(host, port, timeout=0.5, scheme="http"):
attempts.append((host, port, timeout, scheme))
if host == "127.0.0.1":
return {"running": True, "host": host, "port": port, "url": "http://127.0.0.1:9119", "version": "0.12.0"}
return {"running": False}

monkeypatch.setattr(dashboard_probe, "probe_official_dashboard", fake_probe)
result = dashboard_probe.get_dashboard_status(
config_data={"webui": {"dashboard": {"enabled": "auto", "url": "https://dashboard.example.com"}}}
)

assert result == {
"running": True,
"enabled": "auto",
"host": "dashboard.example.com",
"port": 443,
"url": "https://dashboard.example.com",
"external": True,
"version": "0.12.0",
}
assert attempts == [("127.0.0.1", 9119, 0.5, "http")]



Expand Down Expand Up @@ -203,9 +285,5 @@ def test_dashboard_config_roundtrip_writes_profile_config_yaml(tmp_path, monkeyp
assert saved == {"enabled": "auto", "url": "http://127.0.0.1:19119"}
assert "dashboard:" in (tmp_path / "config.yaml").read_text(encoding="utf-8")

try:
save_dashboard_config({"enabled": "auto", "url": "http://example.com:9119"})
except ValueError:
pass
else:
raise AssertionError("external dashboard URL override must be rejected")
saved = save_dashboard_config({"enabled": "always", "url": "https://dashboard.example.com"})
assert saved == {"enabled": "always", "url": "https://dashboard.example.com"}