From 0fd9bf3c37bbc159e71e0a16b4d740df051d83e7 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Sun, 21 Jun 2026 13:07:32 -0700 Subject: [PATCH] =?UTF-8?q?Release=20v0.51.564=20=E2=80=94=20loopback=20si?= =?UTF-8?q?decar=20diagnostics=20(#4612)=20(#4632)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * stage #4612 (santastabber): surface loopback sidecar diagnostics + CHANGELOG Settings -> Extensions surfaces manifest-declared loopback sidecar companion services (type:loopback, http(s) on 127.0.0.1/localhost/::1 only, sanitized origin+health_path) as read-only health cards w/ a browser-side credentials-omitted health probe. Backend hard-whitelists scheme + loopback host, rejects userinfo/path/query/traversal, rebuilds output from parsed components (no echo of rejected input); frontend esc()'s every field, whitelists badge status, fetch never reads body. Purely additive to GET /api/extensions/status. Code applied byte-faithful from PR head 7080929f3727. 33 own tests pass. Nathan concept-approved (loopback-companion direction). Co-authored-by: santastabber * fix #4612 Codex gate findings: reject encoded query/fragment in health_path + align CSP loopback allowlist Codex SHIP-WITH-FIXES (2 reproduced SILENT findings): 1. _normalize_sidecar_health_path percent-decoded AFTER the raw query/fragment ban, so /health%3Ftoken=abc -> ?token=abc survived into the probed URL. Now re-rejects decoded ? and # + regression test (%3F/%23 skipped with sidecar_health_path_rejected). 2. The origin validator accepts https:// and [::1], but CSP connect-src only listed http 127.0.0.1/localhost -> accepted https/IPv6 loopback sidecars would be CSP-blocked. Aligned _CSP_CONNECT_BASE (added https v4/localhost + http(s) [::1]) + updated the 4 exact-string CSP tests + a regression test asserting all loopback forms are allowed. Co-authored-by: santastabber * Release v0.51.564 — Release TW (loopback sidecar diagnostics; #4612) * fix(#4612): drop browser-invalid [::1]:* CSP source (browser-smoke fail) The CSP-loopback-alignment fix added http(s)://[::1]:* to connect-src, but CSP host-source grammar can't express a port wildcard on a bracketed IPv6 literal — Chromium rejects '[::1]:*' as an invalid source, which browser-smoke flags as a console error (CI fail). Keep the valid https://127.0.0.1:*/localhost:* additions; drop the IPv6 wildcard entries. IPv6 [::1] sidecars are still accepted by the validator (displayed in the card); their browser health probe surfaces as 'blocked' under CSP unless an operator adds a specific-port [::1]: via HERMES_WEBUI_CSP_CONNECT_EXTRA. Tests updated to match + assert [::1]:* absent. --------- Co-authored-by: nesquena-hermes Co-authored-by: santastabber --- CHANGELOG.md | 6 + api/extensions.py | 165 ++++++++++- api/helpers.py | 1 + docs/EXTENSIONS.md | 77 ++++- static/panels.js | 90 +++++- static/style.css | 16 ++ tests/test_extension_status_endpoint.py | 326 +++++++++++++++++++++- tests/test_extensions_settings_panel.py | 41 ++- tests/test_issue1909_csp_enforcement.py | 5 +- tests/test_issue2901_csp_connect_extra.py | 32 ++- 10 files changed, 724 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e53ee0f..2be70c27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ ## [Unreleased] +## [v0.51.564] — 2026-06-21 — Release TW (loopback sidecar diagnostics) + +### Added + +- **Settings → Extensions now surfaces loopback "sidecar" companion-service diagnostics.** A manifest extension can declare a loopback sidecar (`type: loopback`, an `http(s)` origin restricted to `127.0.0.1` / `localhost` / `[::1]`, with an optional sanitized health path); WebUI renders it as a read-only "Loopback sidecars" card and shows a live health badge by probing the declared health URL directly from your browser (credentials omitted, response body never read). Origins/paths are strictly validated (no userinfo, no traversal, loopback-only) and rejected declarations are never echoed back. Opt-in via the extension manifest; no server-side proxying. Thanks @santastabber. (#4612) + ## [v0.51.563] — 2026-06-21 — Release TV (per-control composer footer visibility) ### Added diff --git a/api/extensions.py b/api/extensions.py index 18cd1493..ea7a9d04 100644 --- a/api/extensions.py +++ b/api/extensions.py @@ -42,6 +42,9 @@ _EXTENSION_SCRIPT_URLS_ENV = "HERMES_WEBUI_EXTENSION_SCRIPT_URLS" _EXTENSION_STYLESHEET_URLS_ENV = "HERMES_WEBUI_EXTENSION_STYLESHEET_URLS" _EXTENSION_MANIFEST_ENV = "HERMES_WEBUI_EXTENSION_MANIFEST" _ALLOWED_ASSET_PREFIXES = ("/extensions/", "/static/") +_SIDECAR_WARNING_SOURCE = "manifest:sidecars" +_DEFAULT_SIDECAR_HEALTH_PATH = "/health" +_LOOPBACK_SIDECAR_HOSTS = {"127.0.0.1", "localhost", "::1"} _EXTENSION_MIME = { "css": "text/css", @@ -261,6 +264,128 @@ def _manifest_asset_url(value: object) -> str: return EXTENSION_ROUTE_PREFIX + item +def _manifest_entry_text(entry: Dict[str, object], key: str) -> str: + value = entry.get(key) + if not isinstance(value, str): + return "" + return value.strip() + + +def _normalize_loopback_sidecar_origin(value: object) -> Optional[str]: + """Return a canonical loopback origin or None when unsafe. + + Only browser-addressable loopback HTTP(S) origins are accepted. The returned + value is rebuilt from parsed components so rejected raw input is never echoed + into diagnostics. + """ + if not isinstance(value, str): + return None + origin = value.strip() + if not origin or any( + ch in origin for ch in ("\x00", "\r", "\n", '"', "'", "<", ">", "\\") + ): + return None + parsed = urlsplit(origin) + if parsed.scheme not in ("http", "https") or not parsed.netloc: + return None + if parsed.username or parsed.password: + return None + if parsed.path or parsed.query or parsed.fragment: + return None + host = (parsed.hostname or "").lower() + if host not in _LOOPBACK_SIDECAR_HOSTS: + return None + try: + port = parsed.port + except ValueError: + return None + display_host = f"[{host}]" if ":" in host else host + return f"{parsed.scheme}://{display_host}{':' + str(port) if port is not None else ''}" + + +def _normalize_sidecar_health_path(value: object) -> Optional[str]: + """Return a safe sidecar health path, or None when unsafe. + + Health paths are same-origin paths relative to the validated sidecar origin. + Queries are rejected even though they are not cross-origin: health checks are + diagnostics, and query strings often accidentally carry tokens. + """ + if not isinstance(value, str): + return None + path = value.strip() + if not path or not path.startswith("/") or path.startswith("//"): + return None + if any(ch in path for ch in ("\x00", "\r", "\n", '"', "'", "<", ">", "\\")): + return None + parsed = urlsplit(path) + if parsed.scheme or parsed.netloc or parsed.query or parsed.fragment: + return None + decoded_path = _fully_unquote_path(parsed.path) + if any(ch in decoded_path for ch in ("\x00", "\r", "\n", '"', "'", "<", ">", "\\")): + return None + # #4612 (Codex gate): the raw query/fragment ban above runs BEFORE percent- + # decoding, so an encoded delimiter (e.g. "/health%3Ftoken=abc" -> "?token=abc" + # or "/health%23frag" -> "#frag") would survive into the probed URL despite the + # documented query/fragment ban. Re-reject "?" and "#" on the decoded path. + if any(ch in decoded_path for ch in ("?", "#")): + return None + if any(ch.isspace() for ch in decoded_path): + return None + if not decoded_path.startswith("/") or decoded_path.startswith("//"): + return None + segments = decoded_path.split("/")[1:] + if not segments: + return None + for segment in segments: + if not segment or segment in (".", ".."): + return None + return decoded_path + + +def _sidecar_from_manifest_entry( + entry: Dict[str, object], diagnostics: Optional[Dict[str, Any]] = None +) -> Optional[Dict[str, str]]: + raw = entry.get("sidecar") + if raw is None: + return None + if not isinstance(raw, dict): + _add_diagnostic_warning(diagnostics, "sidecar_invalid", _SIDECAR_WARNING_SOURCE) + return None + if raw.get("type") != "loopback": + _add_diagnostic_warning( + diagnostics, "sidecar_type_unsupported", _SIDECAR_WARNING_SOURCE + ) + return None + origin = _normalize_loopback_sidecar_origin(raw.get("origin")) + if origin is None: + _add_diagnostic_warning( + diagnostics, "sidecar_origin_rejected", _SIDECAR_WARNING_SOURCE + ) + return None + if "health_path" in raw: + health_path = _normalize_sidecar_health_path(raw.get("health_path")) + if health_path is None: + # Missing health_path defaults to /health; an explicitly invalid path + # rejects the sidecar so the browser does not probe a declaration the + # administrator needs to fix. + _add_diagnostic_warning( + diagnostics, "sidecar_health_path_rejected", _SIDECAR_WARNING_SOURCE + ) + return None + else: + health_path = _DEFAULT_SIDECAR_HEALTH_PATH + sidecar_id = _manifest_entry_text(entry, "id") + name = _manifest_entry_text(entry, "name") + return { + "id": sidecar_id, + "name": name, + "type": "loopback", + "origin": origin, + "health_path": health_path, + "health_url": f"{origin}{health_path}", + } + + def _iter_manifest_entries(manifest: object) -> List[Tuple[str, object]]: entries: List[Tuple[str, object]] = [] extension_entries: object = [] @@ -294,7 +419,7 @@ def _read_manifest_text(manifest_file: Path) -> str: def _read_manifest_urls_with_diagnostics( root: Path, diagnostics: Optional[Dict[str, Any]] = None -) -> Tuple[List[str], List[str], Dict[str, Any]]: +) -> Tuple[List[str], List[str], List[Dict[str, str]], Dict[str, Any]]: manifest_file, path_status = _manifest_path_with_status(root) manifest_status: Dict[str, Any] = { "configured": path_status != "not_configured", @@ -303,28 +428,29 @@ def _read_manifest_urls_with_diagnostics( "entry_count": 0, "script_count": 0, "stylesheet_count": 0, + "sidecar_count": 0, } if manifest_file is None: if path_status == "invalid_path": _add_diagnostic_warning(diagnostics, "manifest_invalid_path", "manifest") - return [], [], manifest_status + return [], [], [], manifest_status try: if not manifest_file.exists() or not manifest_file.is_file(): _log.warning("Configured extension manifest was not found") manifest_status["status"] = "missing" _add_diagnostic_warning(diagnostics, "manifest_missing", "manifest") - return [], [], manifest_status + return [], [], [], manifest_status manifest = json.loads(_read_manifest_text(manifest_file)) except _ManifestTooLarge: _log.warning("Configured extension manifest exceeds %d bytes", _MAX_MANIFEST_BYTES) manifest_status["status"] = "oversized" _add_diagnostic_warning(diagnostics, "manifest_oversized", "manifest") - return [], [], manifest_status + return [], [], [], manifest_status except json.JSONDecodeError: _log.warning("Configured extension manifest is not valid JSON") manifest_status["status"] = "malformed" _add_diagnostic_warning(diagnostics, "manifest_malformed", "manifest") - return [], [], manifest_status + return [], [], [], manifest_status except RecursionError: # A <=64KB but deeply-nested manifest makes json.loads exceed the # interpreter recursion limit. Without this, the RecursionError escapes @@ -332,15 +458,16 @@ def _read_manifest_urls_with_diagnostics( _log.warning("Configured extension manifest is too deeply nested") manifest_status["status"] = "too_deeply_nested" _add_diagnostic_warning(diagnostics, "manifest_too_deeply_nested", "manifest") - return [], [], manifest_status + return [], [], [], manifest_status except (OSError, UnicodeDecodeError): _log.warning("Configured extension manifest could not be read") manifest_status["status"] = "unreadable" _add_diagnostic_warning(diagnostics, "manifest_unreadable", "manifest") - return [], [], manifest_status + return [], [], [], manifest_status scripts: List[str] = [] stylesheets: List[str] = [] + sidecars: List[Dict[str, str]] = [] entries = _iter_manifest_entries(manifest) manifest_status["entry_count"] = len(entries) scripts_full = False @@ -348,6 +475,15 @@ def _read_manifest_urls_with_diagnostics( for _source, entry in entries: if not isinstance(entry, dict): continue + if _source.startswith("manifest.extensions["): + sidecar = _sidecar_from_manifest_entry(entry, diagnostics) + if sidecar is not None: + if len(sidecars) < _MAX_URL_LIST: + sidecars.append(sidecar) + else: + _add_diagnostic_warning( + diagnostics, "sidecar_list_truncated", _SIDECAR_WARNING_SOURCE + ) script_source = "manifest:scripts" stylesheet_source = "manifest:stylesheets" if not scripts_full: @@ -370,21 +506,20 @@ def _read_manifest_urls_with_diagnostics( ): stylesheets_full = True break - if scripts_full and stylesheets_full: - break manifest_status.update( { "loaded": True, "status": "loaded", "script_count": len(scripts), "stylesheet_count": len(stylesheets), + "sidecar_count": len(sidecars), } ) - return scripts, stylesheets, manifest_status + return scripts, stylesheets, sidecars, manifest_status def _read_manifest_urls(root: Path) -> Tuple[List[str], List[str]]: - scripts, stylesheets, _ = _read_manifest_urls_with_diagnostics(root) + scripts, stylesheets, _, _ = _read_manifest_urls_with_diagnostics(root) return scripts, stylesheets @@ -417,6 +552,7 @@ def get_extension_status() -> Dict[str, Any]: "entry_count": 0, "script_count": 0, "stylesheet_count": 0, + "sidecar_count": 0, } if dir_configured and not dir_valid: _add_diagnostic_warning(diagnostics, "extension_dir_unavailable", "extension_dir") @@ -428,12 +564,13 @@ def get_extension_status() -> Dict[str, Any]: "extension_dir_valid": False, "script_urls": [], "stylesheet_urls": [], - "counts": {"script_urls": 0, "stylesheet_urls": 0}, + "sidecars": [], + "counts": {"script_urls": 0, "stylesheet_urls": 0, "sidecars": 0}, "manifest": manifest_status, "warnings": diagnostics["warnings"], } - manifest_scripts, manifest_stylesheets, manifest_status = _read_manifest_urls_with_diagnostics( + manifest_scripts, manifest_stylesheets, sidecars, manifest_status = _read_manifest_urls_with_diagnostics( root, diagnostics ) script_urls = _read_url_list( @@ -452,9 +589,11 @@ def get_extension_status() -> Dict[str, Any]: "extension_dir_valid": True, "script_urls": script_urls, "stylesheet_urls": stylesheet_urls, + "sidecars": sidecars, "counts": { "script_urls": len(script_urls), "stylesheet_urls": len(stylesheet_urls), + "sidecars": len(sidecars), }, "manifest": manifest_status, "warnings": diagnostics["warnings"], diff --git a/api/helpers.py b/api/helpers.py index 2ac46cc8..a824325d 100644 --- a/api/helpers.py +++ b/api/helpers.py @@ -54,6 +54,7 @@ def safe_resolve(root: Path, requested: str) -> Path: _CSP_CONNECT_BASE = ( "'self' http://127.0.0.1:* http://localhost:* http://ipc.localhost " + "https://127.0.0.1:* https://localhost:* " "ws://127.0.0.1:* ws://localhost:*" ) _CSP_EXTRA_CONNECT_RE = _re.compile( diff --git a/docs/EXTENSIONS.md b/docs/EXTENSIONS.md index 6727154d..f6997077 100644 --- a/docs/EXTENSIONS.md +++ b/docs/EXTENSIONS.md @@ -95,6 +95,30 @@ may be kept in the manifest with the JSON boolean `"enabled": false`. Explicit `HERMES_WEBUI_EXTENSION_STYLESHEET_URLS` still work and are appended after manifest assets, with duplicates ignored. +Extension entries may also declare a read-only loopback sidecar for diagnostics: + +```json +{ + "extensions": [ + { + "id": "desktop-companion", + "name": "Desktop Companion", + "scripts": ["companion-adapter.js"], + "stylesheets": ["companion-adapter.css"], + "sidecar": { + "type": "loopback", + "origin": "http://127.0.0.1:17787", + "health_path": "/health" + } + } + ] +} +``` + +Loopback sidecars do **not** change asset injection behavior. They are only +reported by diagnostics so an operator can see that a local companion service was +declared and optionally check its health from the browser. + ## URL rules Injected asset URLs are deliberately restricted: @@ -153,10 +177,7 @@ control, append the exact origin with `HERMES_WEBUI_CSP_CONNECT_EXTRA` before starting WebUI: ```bash -HERMES_WEBUI_CSP_CONNECT_EXTRA=https://companion.example.internal \ -HERMES_WEBUI_EXTENSION_DIR=/path/to/my-extension/static \ -HERMES_WEBUI_EXTENSION_MANIFEST=extensions.json \ -./start.sh +HERMES_WEBUI_CSP_CONNECT_EXTRA=https://companion.example.internal HERMES_WEBUI_EXTENSION_DIR=/path/to/my-extension/static HERMES_WEBUI_EXTENSION_MANIFEST=extensions.json ./start.sh ``` `HERMES_WEBUI_CSP_CONNECT_EXTRA` accepts space-separated `http(s)://` or @@ -164,6 +185,26 @@ HERMES_WEBUI_EXTENSION_MANIFEST=extensions.json \ numbers. Avoid wildcard or remote origins unless you fully control the target; extension JavaScript runs with the logged-in WebUI session's authority. +## Loopback sidecar declarations + +Sidecar declarations are sanitized before they appear in diagnostics: + +- only `"type": "loopback"` is supported +- `origin` must be an `http` or `https` origin on `127.0.0.1`, `localhost`, or + `[::1]` +- `origin` must not include a username, password, path, query string, or fragment +- `health_path` is optional and defaults to `/health` +- when present, `health_path` must start with `/` and must not contain a scheme, + host, query string, fragment, quotes, control characters, backslashes, empty + segments, whitespace, or path traversal + +Invalid sidecars are skipped with a stable warning code such as +`sidecar_origin_rejected`, `sidecar_type_unsupported`, +`sidecar_health_path_rejected`, or `sidecar_invalid`. Raw rejected origins and +paths are never returned by the status endpoint. If `health_path` is omitted, +diagnostics use `/health`; if `health_path` is present but invalid, the sidecar is +skipped rather than probed. + ## Static file serving When `HERMES_WEBUI_EXTENSION_DIR` points at an existing directory, files under @@ -307,12 +348,22 @@ not enable, disable, install, or mutate extensions; it only fetches `/api/extensions/status` and offers a copy-diagnostics action. The diagnostics return the same public asset URLs that can already be injected -into the HTML, plus coarse manifest status, asset counts, and warning codes for -rejected or unavailable configuration. `manifest.script_count` and -`manifest.stylesheet_count` count accepted assets from the manifest only; -`counts.script_urls` and `counts.stylesheet_urls` count the final post-env-merge -URLs. `manifest.entry_count` counts the loaded top-level manifest object and -enabled extension entries that were inspected, not every extension object in the -file. The endpoint and Settings panel do **not** return -`HERMES_WEBUI_EXTENSION_DIR`, resolved manifest paths, raw environment values, or -rejected URL strings. +into the HTML, plus coarse manifest status, asset counts, sanitized declared +loopback sidecars, and warning codes for rejected or unavailable configuration. +`manifest.script_count` and `manifest.stylesheet_count` count accepted assets +from the manifest only; `manifest.sidecar_count` counts accepted enabled loopback +sidecars from the manifest. `counts.script_urls` and `counts.stylesheet_urls` +count the final post-env-merge URLs, while `counts.sidecars` counts the sanitized +sidecar list returned in `sidecars`. `manifest.entry_count` counts the loaded +top-level manifest object and enabled extension entries that were inspected, not +every extension object in the file. The endpoint and Settings panel do **not** +return `HERMES_WEBUI_EXTENSION_DIR`, resolved manifest paths, raw environment +values, rejected URL strings, rejected sidecar origins, or rejected health paths. + +When sanitized loopback sidecars are present, **Settings → Extensions** renders a +read-only sidecar monitor card. The browser checks each declared `health_url` +directly with `fetch(..., { credentials: 'omit', cache: 'no-store' })` and a +short timeout. WebUI does **not** proxy sidecar requests and does not send WebUI +cookies to sidecars. A successful HTTP response is shown as healthy, a non-OK +HTTP response as unhealthy, and CORS/network/timeouts as unreachable or blocked; +health response bodies are never rendered. diff --git a/static/panels.js b/static/panels.js index 1b8e228d..3e14bc43 100644 --- a/static/panels.js +++ b/static/panels.js @@ -6227,6 +6227,7 @@ let _settingsIndex = null; let _settingsIndexPromise = null; let _settingsSearchSeq = 0; let _extensionsStatusData = null; +let _extensionsSidecarMonitorSeq = 0; let _settingsSearchDismissListenerRegistered = false; let _settingsAppearanceAutosaveTimer = null; let _settingsAppearanceAutosaveRetryPayload = null; @@ -7627,7 +7628,84 @@ function _extensionCountValue(counts,key,urls){ return Array.isArray(urls)?urls.length:0; } -function _renderExtensionsPanel(data){ +function _extensionSidecarHealthBadge(status,label){ + const safeStatus=['checking','healthy','unhealthy','blocked'].includes(status)?status:'checking'; + return `${esc(label||safeStatus)}`; +} + +function _extensionSidecarCard(sidecars){ + const list=Array.isArray(sidecars)?sidecars:[]; + const body=list.length?`
${list.map((sidecar,index)=>{ + const id=(sidecar&&sidecar.id)||''; + const name=(sidecar&&sidecar.name)||''; + const title=name||id||'Unnamed extension'; + const meta=(name&&id)?id:(sidecar&&sidecar.type)||'loopback'; + const origin=(sidecar&&sidecar.origin)||''; + const healthPath=(sidecar&&sidecar.health_path)||''; + const healthUrl=(sidecar&&sidecar.health_url)||''; + return `
+
+
${esc(title)}
+ ${_extensionSidecarHealthBadge('checking','checking')} +
+
${esc(meta)}
+
+
Origin${esc(origin)}
+
Health path${esc(healthPath)}
+
Health URL${esc(healthUrl)}
+
+
`; + }).join('')}
`:'
No loopback sidecars declared.
'; + return ` +
+
+
+
Loopback sidecars
+
Declared local companions; health is checked directly from this browser with WebUI credentials omitted.
+
+
+
+ ${body} +
+
`; +} + +function _setExtensionSidecarHealth(index,status,label){ + const el=document.querySelector(`[data-sidecar-health-index="${index}"]`); + if(el) el.innerHTML=_extensionSidecarHealthBadge(status,label); +} + +async function _checkExtensionSidecarHealth(sidecar,index,seq){ + const healthUrl=sidecar&&sidecar.health_url; + if(!healthUrl){ + _setExtensionSidecarHealth(index,'blocked','unreachable / blocked'); + return; + } + let controller=null; + let timeoutId=null; + try{ + if(typeof AbortController!=='undefined'){ + controller=new AbortController(); + timeoutId=setTimeout(()=>controller.abort(),2500); + } + const res=await fetch(healthUrl,{credentials:'omit',cache:'no-store',signal:controller?controller.signal:undefined}); + if(seq!==_extensionsSidecarMonitorSeq) return; + if(res.ok) _setExtensionSidecarHealth(index,'healthy','healthy'); + else _setExtensionSidecarHealth(index,'unhealthy','unhealthy'); + }catch(_e){ + if(seq!==_extensionsSidecarMonitorSeq) return; + _setExtensionSidecarHealth(index,'blocked','unreachable / blocked'); + }finally{ + if(timeoutId) clearTimeout(timeoutId); + } +} + +function _monitorExtensionSidecars(sidecars,seq){ + if(!Array.isArray(sidecars)||sidecars.length===0) return; + sidecars.forEach((sidecar,index)=>_checkExtensionSidecarHealth(sidecar,index,seq)); +} + +function _renderExtensionsPanel(data,seq){ const target=$('extensionsDiagnostics'); const copyBtn=$('extensionsCopyDiagnosticsBtn'); if(!target) return; @@ -7637,9 +7715,11 @@ function _renderExtensionsPanel(data){ const counts=(data&&data.counts)||{}; const scripts=Array.isArray(data&&data.script_urls)?data.script_urls:[]; const styles=Array.isArray(data&&data.stylesheet_urls)?data.stylesheet_urls:[]; + const sidecars=Array.isArray(data&&data.sidecars)?data.sidecars:[]; const statusClass=(data&&data.enabled)?'extension-card-enabled':'extension-card-disabled'; const scriptCount=_extensionCountValue(counts,'script_urls',scripts); const styleCount=_extensionCountValue(counts,'stylesheet_urls',styles); + const sidecarCount=_extensionCountValue(counts,'sidecars',sidecars); target.innerHTML=`
@@ -7659,8 +7739,10 @@ function _renderExtensionsPanel(data){
Manifest entries inspected${Number(manifest.entry_count)||0}
Manifest script count${Number(manifest.script_count)||0}
Manifest stylesheet count${Number(manifest.stylesheet_count)||0}
+
Manifest sidecar count${Number(manifest.sidecar_count)||0}
Final script count${scriptCount}
Final stylesheet count${styleCount}
+
Loopback sidecar count${sidecarCount}
@@ -7678,6 +7760,7 @@ function _renderExtensionsPanel(data){ ${_extensionAssetList(styles)} + ${_extensionSidecarCard(sidecars)}
@@ -7690,6 +7773,7 @@ function _renderExtensionsPanel(data){
`; + _monitorExtensionSidecars(sidecars,seq); } async function loadExtensionsPanel(){ @@ -7697,10 +7781,12 @@ async function loadExtensionsPanel(){ const copyBtn=$('extensionsCopyDiagnosticsBtn'); if(!target) return; if(copyBtn) copyBtn.disabled=true; + const seq=++_extensionsSidecarMonitorSeq; target.innerHTML='
Loading extension diagnostics…
'; try{ const data=await api('/api/extensions/status'); - _renderExtensionsPanel(data); + if(seq!==_extensionsSidecarMonitorSeq) return; + _renderExtensionsPanel(data,seq); }catch(e){ _extensionsStatusData=null; if(copyBtn) copyBtn.disabled=true; diff --git a/static/style.css b/static/style.css index 02130c58..d2cf3b78 100644 --- a/static/style.css +++ b/static/style.css @@ -4606,9 +4606,25 @@ main.main > #mainPlugin{display:none;} .extension-url-list code,.extension-warning-list code{font-family:var(--font-mono);font-size:11px;color:var(--text);overflow-wrap:anywhere;word-break:break-word;} .extension-warning-list span{font-size:11px;color:var(--muted);overflow-wrap:anywhere;} .extension-url-empty{margin-top:6px;font-size:12px;color:var(--muted);font-style:italic;} +.extension-sidecar-list{display:flex;flex-direction:column;gap:8px;} +.extension-sidecar-row{padding:9px 10px;border:1px solid var(--border);border-radius:8px;background:var(--surface);min-width:0;} +.extension-sidecar-row-head{display:flex;align-items:center;justify-content:space-between;gap:10px;min-width:0;} +.extension-sidecar-title{font-size:13px;font-weight:600;color:var(--text);overflow-wrap:anywhere;min-width:0;} +.extension-sidecar-meta{margin-top:2px;font-size:11px;color:var(--muted);overflow-wrap:anywhere;} +.extension-sidecar-fields{display:grid;grid-template-columns:1fr;gap:5px;margin-top:8px;} +.extension-sidecar-fields>div{display:flex;align-items:flex-start;gap:8px;min-width:0;font-size:12px;color:var(--muted);} +.extension-sidecar-fields span{flex:0 0 82px;} +.extension-sidecar-fields code{font-family:var(--font-mono);font-size:11px;color:var(--text);overflow-wrap:anywhere;word-break:break-word;} +.extension-sidecar-status-badge{display:inline-flex;align-items:center;white-space:nowrap;border-radius:999px;padding:2px 7px;font-size:10.5px;font-weight:700;font-family:var(--font-mono);line-height:1.45;background:var(--code-bg);color:var(--muted);} +.extension-sidecar-status-healthy{background:rgba(34,197,94,.14);color:#22c55e;} +.extension-sidecar-status-unhealthy{background:rgba(245,158,11,.14);color:#f59e0b;} +.extension-sidecar-status-blocked{background:rgba(239,68,68,.14);color:var(--error);} @media (max-width: 768px){ .extensions-copy-diagnostics-btn{width:100%;} .extension-summary-grid{grid-template-columns:1fr;} + .extension-sidecar-row-head{align-items:flex-start;flex-direction:column;} + .extension-sidecar-fields>div{flex-direction:column;gap:2px;} + .extension-sidecar-fields span{flex:0 0 auto;} } /* ── Provider model tags ── */ diff --git a/tests/test_extension_status_endpoint.py b/tests/test_extension_status_endpoint.py index f174f6ec..9e52811a 100644 --- a/tests/test_extension_status_endpoint.py +++ b/tests/test_extension_status_endpoint.py @@ -58,7 +58,8 @@ def test_extension_status_disabled_by_default(): "extension_dir_valid": False, "script_urls": [], "stylesheet_urls": [], - "counts": {"script_urls": 0, "stylesheet_urls": 0}, + "sidecars": [], + "counts": {"script_urls": 0, "stylesheet_urls": 0, "sidecars": 0}, "manifest": { "configured": False, "loaded": False, @@ -66,6 +67,7 @@ def test_extension_status_disabled_by_default(): "entry_count": 0, "script_count": 0, "stylesheet_count": 0, + "sidecar_count": 0, }, "warnings": [], } @@ -124,7 +126,8 @@ def test_extension_status_reports_loaded_manifest_counts_and_urls(tmp_path, monk "/extensions/base.css", "/extensions/templates/app.css", ] - assert status["counts"] == {"script_urls": 3, "stylesheet_urls": 2} + assert status["sidecars"] == [] + assert status["counts"] == {"script_urls": 3, "stylesheet_urls": 2, "sidecars": 0} assert status["manifest"] == { "configured": True, "loaded": True, @@ -132,6 +135,7 @@ def test_extension_status_reports_loaded_manifest_counts_and_urls(tmp_path, monk "entry_count": 2, "script_count": 2, "stylesheet_count": 2, + "sidecar_count": 0, } assert status["warnings"] == [] @@ -163,6 +167,8 @@ def test_extension_status_ignores_non_dict_manifest_extensions_in_entry_count( assert status["script_urls"] == ["/extensions/templates/app.js"] assert status["manifest"]["entry_count"] == 2 assert status["manifest"]["script_count"] == 1 + assert status["manifest"]["sidecar_count"] == 0 + assert status["sidecars"] == [] assert status["warnings"] == [] @@ -332,6 +338,322 @@ def test_extension_status_reports_rejected_env_assets_without_rejected_values(tm assert "env.js" not in rendered +def test_extension_status_reports_sanitized_loopback_sidecars(tmp_path, monkeypatch): + root = tmp_path / "extensions" + root.mkdir() + (root / "extensions.json").write_text( + """ + { + "extensions": [ + { + "id": "desktop-companion", + "name": "Desktop Companion", + "scripts": ["companion-adapter.js"], + "stylesheets": ["companion-adapter.css"], + "sidecar": { + "type": "loopback", + "origin": "http://127.0.0.1:17787", + "health_path": "/health" + } + }, + { + "id": "implicit-health", + "sidecar": { + "type": "loopback", + "origin": "http://localhost:17788" + } + }, + { + "id": "ipv6-loopback", + "sidecar": { + "type": "loopback", + "origin": "http://[::1]:17789", + "health_path": "/ready" + } + } + ] + } + """, + encoding="utf-8", + ) + monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root)) + monkeypatch.setenv("HERMES_WEBUI_EXTENSION_MANIFEST", "extensions.json") + + from api.extensions import get_extension_status + + status = get_extension_status() + assert status["script_urls"] == ["/extensions/companion-adapter.js"] + assert status["stylesheet_urls"] == ["/extensions/companion-adapter.css"] + assert status["sidecars"] == [ + { + "id": "desktop-companion", + "name": "Desktop Companion", + "type": "loopback", + "origin": "http://127.0.0.1:17787", + "health_path": "/health", + "health_url": "http://127.0.0.1:17787/health", + }, + { + "id": "implicit-health", + "name": "", + "type": "loopback", + "origin": "http://localhost:17788", + "health_path": "/health", + "health_url": "http://localhost:17788/health", + }, + { + "id": "ipv6-loopback", + "name": "", + "type": "loopback", + "origin": "http://[::1]:17789", + "health_path": "/ready", + "health_url": "http://[::1]:17789/ready", + }, + ] + assert status["counts"]["sidecars"] == 3 + assert status["manifest"]["sidecar_count"] == 3 + assert status["warnings"] == [] + + +def test_extension_status_skips_disabled_sidecar_entries(tmp_path, monkeypatch): + root = tmp_path / "extensions" + root.mkdir() + (root / "extensions.json").write_text( + """ + { + "extensions": [ + { + "id": "off", + "enabled": false, + "sidecar": {"type": "loopback", "origin": "http://127.0.0.1:17787"} + } + ] + } + """, + encoding="utf-8", + ) + monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root)) + monkeypatch.setenv("HERMES_WEBUI_EXTENSION_MANIFEST", "extensions.json") + + from api.extensions import get_extension_status + + status = get_extension_status() + assert status["sidecars"] == [] + # The top-level manifest object is still inspected; the disabled extension + # entry is skipped and must not contribute a sidecar. + assert status["manifest"]["entry_count"] == 1 + assert status["manifest"]["sidecar_count"] == 0 + assert status["warnings"] == [] + + +def test_extension_status_rejects_non_loopback_sidecars_without_raw_value_leak(tmp_path, monkeypatch): + root = tmp_path / "extensions" + root.mkdir() + (root / "extensions.json").write_text( + """ + { + "extensions": [ + { + "id": "bad-origin", + "sidecar": { + "type": "loopback", + "origin": "http://10.0.0.5:17787", + "health_path": "/health" + } + } + ] + } + """, + encoding="utf-8", + ) + monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root)) + monkeypatch.setenv("HERMES_WEBUI_EXTENSION_MANIFEST", "extensions.json") + + from api.extensions import get_extension_status + + status = get_extension_status() + assert status["sidecars"] == [] + assert status["manifest"]["sidecar_count"] == 0 + assert status["warnings"] == [ + {"code": "sidecar_origin_rejected", "source": "manifest:sidecars"} + ] + rendered = repr(status) + assert "10.0.0.5" not in rendered + assert "17787" not in rendered + + +def test_extension_status_rejects_invalid_sidecar_health_path_without_leak(tmp_path, monkeypatch): + root = tmp_path / "extensions" + root.mkdir() + (root / "extensions.json").write_text( + """ + { + "extensions": [ + { + "id": "bad-health", + "sidecar": { + "type": "loopback", + "origin": "http://127.0.0.1:17787", + "health_path": "/../secret-health" + } + } + ] + } + """, + encoding="utf-8", + ) + monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root)) + monkeypatch.setenv("HERMES_WEBUI_EXTENSION_MANIFEST", "extensions.json") + + from api.extensions import get_extension_status + + status = get_extension_status() + assert status["sidecars"] == [] + assert status["manifest"]["sidecar_count"] == 0 + assert status["warnings"] == [ + {"code": "sidecar_health_path_rejected", "source": "manifest:sidecars"} + ] + assert "secret-health" not in repr(status) + + +def test_extension_status_rejects_decoded_whitespace_sidecar_health_path(tmp_path, monkeypatch): + root = tmp_path / "extensions" + root.mkdir() + (root / "extensions.json").write_text( + """ + { + "extensions": [ + { + "id": "bad-health-space", + "sidecar": { + "type": "loopback", + "origin": "http://127.0.0.1:17787", + "health_path": "/health%20check" + } + } + ] + } + """, + encoding="utf-8", + ) + monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root)) + monkeypatch.setenv("HERMES_WEBUI_EXTENSION_MANIFEST", "extensions.json") + + from api.extensions import get_extension_status + + status = get_extension_status() + assert status["sidecars"] == [] + assert status["warnings"] == [ + {"code": "sidecar_health_path_rejected", "source": "manifest:sidecars"} + ] + rendered = repr(status) + assert "health check" not in rendered + assert "health%20check" not in rendered + + +def test_extension_status_rejects_encoded_query_or_fragment_sidecar_health_path(tmp_path, monkeypatch): + """#4612 (Codex gate): the raw query/fragment ban runs BEFORE percent-decoding, + so an encoded delimiter ("/health%3Ftoken=abc" -> "?token=abc", "/health%23frag" + -> "#frag") must be re-rejected on the decoded path — otherwise a query/fragment + survives into the browser-probed health URL despite the documented ban.""" + for bad_path, leaked in ( + ("/health%3Ftoken=abc", "token=abc"), + ("/health%23frag", "frag"), + ): + root = tmp_path / f"extensions_{leaked}" + root.mkdir() + (root / "extensions.json").write_text( + """ + { + "extensions": [ + { + "id": "bad-health-delim", + "sidecar": { + "type": "loopback", + "origin": "http://127.0.0.1:17787", + "health_path": "%s" + } + } + ] + } + """ % bad_path, + encoding="utf-8", + ) + monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root)) + monkeypatch.setenv("HERMES_WEBUI_EXTENSION_MANIFEST", "extensions.json") + + import importlib + import api.extensions as _ext + importlib.reload(_ext) + status = _ext.get_extension_status() + assert status["sidecars"] == [], f"{bad_path} must be rejected, not probed" + assert {"code": "sidecar_health_path_rejected", "source": "manifest:sidecars"} in status["warnings"] + rendered = repr(status) + assert leaked not in rendered, f"rejected {bad_path} must not leak {leaked!r} into status" + + +def test_extension_status_rejects_unsupported_sidecar_type_without_origin_probe(tmp_path, monkeypatch): + root = tmp_path / "extensions" + root.mkdir() + (root / "extensions.json").write_text( + """ + { + "extensions": [ + { + "id": "unsupported", + "sidecar": {"type": "unix-socket", "origin": "http://127.0.0.1:17787"} + } + ] + } + """, + encoding="utf-8", + ) + monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root)) + monkeypatch.setenv("HERMES_WEBUI_EXTENSION_MANIFEST", "extensions.json") + + from api.extensions import get_extension_status + + status = get_extension_status() + assert status["sidecars"] == [] + assert status["warnings"] == [ + {"code": "sidecar_type_unsupported", "source": "manifest:sidecars"} + ] + + +def test_extension_status_truncates_many_sidecars_with_sanitized_warning(tmp_path, monkeypatch): + import json + + root = tmp_path / "extensions" + root.mkdir() + entries = [ + { + "id": f"sidecar-{index}", + "sidecar": { + "type": "loopback", + "origin": f"http://127.0.0.1:{18000 + index}", + }, + } + for index in range(40) + ] + (root / "extensions.json").write_text( + json.dumps({"extensions": entries}), + encoding="utf-8", + ) + monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root)) + monkeypatch.setenv("HERMES_WEBUI_EXTENSION_MANIFEST", "extensions.json") + + from api.extensions import get_extension_status + + status = get_extension_status() + assert len(status["sidecars"]) == 32 + assert status["counts"]["sidecars"] == 32 + assert status["manifest"]["sidecar_count"] == 32 + assert status["warnings"] == [ + {"code": "sidecar_list_truncated", "source": "manifest:sidecars"} + ] + + + def test_extension_status_route_is_wired(monkeypatch): from api import routes diff --git a/tests/test_extensions_settings_panel.py b/tests/test_extensions_settings_panel.py index c0e85cdf..5e5177ae 100644 --- a/tests/test_extensions_settings_panel.py +++ b/tests/test_extensions_settings_panel.py @@ -109,8 +109,11 @@ def test_extensions_panel_renders_sanitized_status_payload(): assert "manifest.entry_count" in render_block assert "manifest.script_count" in render_block assert "manifest.stylesheet_count" in render_block + assert "manifest.sidecar_count" in render_block assert "script_urls" in render_block assert "stylesheet_urls" in render_block + assert "data&&data.sidecars" in render_block + assert "_extensionSidecarCard(sidecars)" in render_block assert "data&&data.warnings" in render_block assert "esc(url)" in asset_block assert "esc(manifest.status||'unknown')" in render_block @@ -119,6 +122,36 @@ def test_extensions_panel_renders_sanitized_status_payload(): assert "Rejected" not in render_block # rejected values must never be rendered directly +def test_extensions_panel_renders_loopback_sidecar_monitor_safely(): + sidecar_block = _between("function _extensionSidecarCard", "function _setExtensionSidecarHealth") + monitor_block = _between("async function _checkExtensionSidecarHealth", "function _renderExtensionsPanel") + render_block = _between("function _renderExtensionsPanel", "async function loadExtensionsPanel") + load_block = _between("async function loadExtensionsPanel", "async function copyExtensionsDiagnostics") + + assert "Loopback sidecars" in sidecar_block + assert "No loopback sidecars declared." in sidecar_block + assert "esc(title)" in sidecar_block + assert "esc(meta)" in sidecar_block + assert "esc(origin)" in sidecar_block + assert "esc(healthPath)" in sidecar_block + assert "esc(healthUrl)" in sidecar_block + assert "fetch(healthUrl,{credentials:'omit',cache:'no-store'" in monitor_block + assert "function _monitorExtensionSidecars(sidecars,seq)" in monitor_block + assert "const seq=_extensionsSidecarMonitorSeq" not in monitor_block + assert "_monitorExtensionSidecars(sidecars,seq)" in render_block + assert "function _renderExtensionsPanel(data,seq)" in render_block + assert "const seq=++_extensionsSidecarMonitorSeq" in load_block + assert "if(seq!==_extensionsSidecarMonitorSeq) return;" in load_block + assert "_renderExtensionsPanel(data,seq)" in load_block + assert "res.ok" in monitor_block + assert "res.text" not in monitor_block + assert "res.json" not in monitor_block + assert "api('/api/settings'" not in monitor_block + assert "api('/extensions/" not in monitor_block + assert "sidecar/*" not in monitor_block + assert not _contains_post_method(monitor_block) + + def test_copy_extensions_diagnostics_copies_current_sanitized_payload(): copy_block = _between("function copyExtensionsDiagnostics", "// ── Plugins panel") @@ -135,6 +168,8 @@ def test_extensions_styles_are_scoped_to_extensions_panel(): assert ".extension-summary-grid" in STYLE_CSS assert ".extension-warning-list" in STYLE_CSS assert ".extension-url-list" in STYLE_CSS + assert ".extension-sidecar-list" in STYLE_CSS + assert ".extension-sidecar-status-badge" in STYLE_CSS def test_extensions_tab_i18n_key_exists_for_all_locales(): @@ -151,4 +186,8 @@ def test_extensions_docs_mentions_settings_panel_without_mutation_claims(): assert "Settings → Extensions" in diagnostics_section assert "enable, disable, install, or mutate extensions" in diagnostics_section assert "`/api/extensions/status`" in diagnostics_section - assert "do **not** return" in diagnostics_section + assert "sanitized loopback sidecars" in diagnostics_section + assert "credentials: 'omit'" in diagnostics_section + assert "does **not** proxy sidecar requests" in diagnostics_section + assert "do **not**" in diagnostics_section + assert "return `HERMES_WEBUI_EXTENSION_DIR`" in diagnostics_section diff --git a/tests/test_issue1909_csp_enforcement.py b/tests/test_issue1909_csp_enforcement.py index 4eb8ad19..aa018171 100644 --- a/tests/test_issue1909_csp_enforcement.py +++ b/tests/test_issue1909_csp_enforcement.py @@ -50,7 +50,7 @@ def test_security_helper_sends_enforcing_csp_with_hardening_directives(monkeypat assert "object-src 'none'" in policy assert "frame-ancestors 'none'" in policy assert "media-src 'self' data: blob:" in policy - assert "connect-src 'self' http://127.0.0.1:* http://localhost:* http://ipc.localhost ws://127.0.0.1:* ws://localhost:* https://cdn.jsdelivr.net" in policy + assert "connect-src 'self' http://127.0.0.1:* http://localhost:* http://ipc.localhost https://127.0.0.1:* https://localhost:* ws://127.0.0.1:* ws://localhost:* https://cdn.jsdelivr.net" in policy def test_enforcing_csp_honors_valid_extra_connect_origins(monkeypatch): @@ -64,7 +64,8 @@ def test_enforcing_csp_honors_valid_extra_connect_origins(monkeypatch): policy = headers["Content-Security-Policy"] assert ( "connect-src 'self' http://127.0.0.1:* http://localhost:* " - "http://ipc.localhost ws://127.0.0.1:* ws://localhost:* https://cdn.jsdelivr.net " + "http://ipc.localhost https://127.0.0.1:* https://localhost:* " + "ws://127.0.0.1:* ws://localhost:* https://cdn.jsdelivr.net " "https://metrics.example.com wss://events.example.com:443; " ) in policy diff --git a/tests/test_issue2901_csp_connect_extra.py b/tests/test_issue2901_csp_connect_extra.py index 0039c29a..f3f6c748 100644 --- a/tests/test_issue2901_csp_connect_extra.py +++ b/tests/test_issue2901_csp_connect_extra.py @@ -11,7 +11,9 @@ def test_csp_connect_src_default_header_unchanged(monkeypatch): policy = Handler.csp_report_only_policy() expected = ( "connect-src 'self' http://127.0.0.1:* http://localhost:* " - "http://ipc.localhost ws://127.0.0.1:* ws://localhost:* " + "http://ipc.localhost " + "https://127.0.0.1:* https://localhost:* " + "ws://127.0.0.1:* ws://localhost:* " "https://cdn.jsdelivr.net; " ) @@ -30,12 +32,38 @@ def test_csp_connect_src_includes_valid_extra_origins(monkeypatch): assert ( "connect-src 'self' http://127.0.0.1:* http://localhost:* " - "http://ipc.localhost ws://127.0.0.1:* ws://localhost:* " + "http://ipc.localhost " + "https://127.0.0.1:* https://localhost:* " + "ws://127.0.0.1:* ws://localhost:* " "https://cdn.jsdelivr.net " "https://metrics.example.com wss://events.example.com:443; " ) in policy +def test_csp_connect_src_allows_https_loopback_for_sidecars(monkeypatch): + """#4612 (Codex gate): the sidecar origin validator accepts https:// in + addition to http on 127.0.0.1/localhost, but the default CSP connect-src only + listed the http forms — so an accepted https loopback sidecar probe would be + blocked by CSP. The base policy must allow the https loopback forms. + + (Note: IPv6 [::1] sidecars are accepted by the validator for display, but CSP + host-source grammar can't express a port wildcard on a bracketed IPv6 literal + — `[::1]:*` is rejected by the browser — so the IPv6 health probe is expected + to surface as 'blocked'. An operator who needs it can add a specific-port + `http://[::1]:` entry via HERMES_WEBUI_CSP_CONNECT_EXTRA.)""" + from server import Handler + + monkeypatch.delenv("HERMES_WEBUI_CSP_CONNECT_EXTRA", raising=False) + policy = Handler.csp_report_only_policy() + for origin in ( + "https://127.0.0.1:*", + "https://localhost:*", + ): + assert origin in policy, f"CSP connect-src must allow {origin} for sidecar health probes" + # The invalid IPv6 port-wildcard form must NOT be present (browser rejects it). + assert "[::1]:*" not in policy, "CSP must not contain the browser-invalid [::1]:* source" + + def test_csp_connect_src_includes_explicit_trusted_sidecar_origin(monkeypatch): from server import Handler