* 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 <santastabber@users.noreply.github.com> * 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 <santastabber@users.noreply.github.com> * 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]:<port> via HERMES_WEBUI_CSP_CONNECT_EXTRA. Tests updated to match + assert [::1]:* absent. --------- Co-authored-by: nesquena-hermes <agent@nesquena-hermes> Co-authored-by: santastabber <santastabber@users.noreply.github.com>
This commit is contained in:
parent
b549c004cb
commit
0fd9bf3c37
10 changed files with 724 additions and 35 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 `<span class="extension-sidecar-status-badge extension-sidecar-status-${safeStatus}">${esc(label||safeStatus)}</span>`;
|
||||
}
|
||||
|
||||
function _extensionSidecarCard(sidecars){
|
||||
const list=Array.isArray(sidecars)?sidecars:[];
|
||||
const body=list.length?`<div class="extension-sidecar-list">${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 `<div class="extension-sidecar-row" data-sidecar-index="${index}">
|
||||
<div class="extension-sidecar-row-head">
|
||||
<div class="extension-sidecar-title">${esc(title)}</div>
|
||||
<span id="extensionSidecarHealth${index}" data-sidecar-health-index="${index}">${_extensionSidecarHealthBadge('checking','checking')}</span>
|
||||
</div>
|
||||
<div class="extension-sidecar-meta">${esc(meta)}</div>
|
||||
<div class="extension-sidecar-fields">
|
||||
<div><span>Origin</span><code>${esc(origin)}</code></div>
|
||||
<div><span>Health path</span><code>${esc(healthPath)}</code></div>
|
||||
<div><span>Health URL</span><code>${esc(healthUrl)}</code></div>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('')}</div>`:'<div class="extension-url-empty">No loopback sidecars declared.</div>';
|
||||
return `
|
||||
<div class="provider-card extension-sidecars-card">
|
||||
<div class="provider-card-header plugin-card-header">
|
||||
<div class="provider-card-info">
|
||||
<div class="provider-card-name">Loopback sidecars</div>
|
||||
<div class="provider-card-meta">Declared local companions; health is checked directly from this browser with WebUI credentials omitted.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="provider-card-body extension-card-body">
|
||||
${body}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
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=`
|
||||
<div class="provider-card extension-status-card ${statusClass}">
|
||||
<div class="provider-card-header plugin-card-header">
|
||||
|
|
@ -7659,8 +7739,10 @@ function _renderExtensionsPanel(data){
|
|||
<div><span>Manifest entries inspected</span><code>${Number(manifest.entry_count)||0}</code></div>
|
||||
<div><span>Manifest script count</span><code>${Number(manifest.script_count)||0}</code></div>
|
||||
<div><span>Manifest stylesheet count</span><code>${Number(manifest.stylesheet_count)||0}</code></div>
|
||||
<div><span>Manifest sidecar count</span><code>${Number(manifest.sidecar_count)||0}</code></div>
|
||||
<div><span>Final script count</span><code>${scriptCount}</code></div>
|
||||
<div><span>Final stylesheet count</span><code>${styleCount}</code></div>
|
||||
<div><span>Loopback sidecar count</span><code>${sidecarCount}</code></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -7678,6 +7760,7 @@ function _renderExtensionsPanel(data){
|
|||
${_extensionAssetList(styles)}
|
||||
</div>
|
||||
</div>
|
||||
${_extensionSidecarCard(sidecars)}
|
||||
<div class="provider-card extension-warnings-card">
|
||||
<div class="provider-card-header plugin-card-header">
|
||||
<div class="provider-card-info">
|
||||
|
|
@ -7690,6 +7773,7 @@ function _renderExtensionsPanel(data){
|
|||
</div>
|
||||
</div>
|
||||
`;
|
||||
_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='<div class="extensions-loading">Loading extension diagnostics…</div>';
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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 ── */
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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]:<port>` 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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue