feat(extensions): token-v1 proxy→sidecar authentication boundary (#6331)

* feat(extensions): token-v1 proxy->sidecar auth boundary

Mint a per-extension secret core injects (X-Hermes-Sidecar-Token) on every
proxied request; sidecars validate it. Closes the hole where a loopback sidecar
port is reachable by any local process and cannot distinguish a proxied request
from a direct one.

- api/extension_sidecar_auth.py: per-extension token lifecycle (atomic mint,
  re-read-verified so an unpersisted token is never injected, per-request
  mtime-cached read for live rotation, path-escape-safe)
- manifest proxy_auth negotiation: absent=legacy, token-v1=enforce, unknown=fail-closed
- proxy: inject token, strip inbound + response x-hermes-*, auth-off posture
  (loopback-only local_unprotected, else 503), fail-closed when token unavailable
- consent-time auth_required in status payload; mint-on-consent
- docs/EXTENSIONS.md proxy_auth section
- 7 new tests (26/26 green)

* fix(extensions): address Codex+Fable gate on token-v1 (6 findings)

- align token-module extension-id grammar with core _EXTENSION_ID_RE (was
  narrower -> legally-named ext consented then 503'd forever)
- resolve token dir dynamically (mirror _extension_state_dir) -> real test
  isolation, no import-time STATE_DIR cache
- cross-process mint: O_CREAT|O_EXCL no-clobber claim (was os.replace clobber)
- rotation cache keyed on full fingerprint (ino/dev/mtime/ctime/size) +
  re-fingerprint after read -> same-size/mtime replacement no longer stale
- validate token format on read (url-safe, 16-256) -> malformed file can't
  leak via a ValueError echoed in a 502
- consent fails 503 when token can't be provisioned (was silent-swallow ->
  persisted consent then 503 forever)
- rename status auth_required -> posture enum (protected|local_unprotected):
  nothing is blocked for loopback, so 'required' was misleading
- panels.js: render local_unprotected warning on the consent row (+ CSS)
- docs: token-path resolution order, 401-vs-503, explicit 'legacy' acceptance
- tests: route-level token-injection+response-strip test; fix illusory isolation
  in token-module test; 27/27 green

* fix(extensions): close 2 token-mint races (Codex re-gate round 2)

- mint via temp-file + atomic os.replace (not O_CREAT|O_EXCL) so the final
  path is never observed empty/half-written — a concurrent loser can no longer
  read an empty token file and 503
- single _stable_read helper (fingerprint-read-refingerprint, bounded retry on
  mid-read change) used by BOTH ensure_token and current_token — a token that
  changes during the read is never returned or cached stale
- stress-verified: 20 concurrent first-mints converge on 1 persisted token; 27/27

* fix(extensions): atomic no-clobber token publish via os.link (Codex re-gate round 3)

os.replace fixed empty-file exposure but still clobbered cross-process: two
processes could both write+replace and a reader between them got a token no
longer on disk -> 401. Switch to the repo's TOCTOU-safe os.link create-or-fail
idiom (session_recovery.py:627): write temp -> link into place (fails if a
winner already published) -> loser drops its temp and reads the winner via
_stable_read. Proven: 16 concurrent PROCESSES converge on 1 persisted token,
all matching disk. 27/27 tests green.

* fix(extensions): resolve Frank+Greptile #6331 review — token-v1 fail-closed when auth off (consent+resolution), token-v1-only proxy_auth/posture status fields, fullmatch ext-id validator

* fix(extensions): finish Frank and Greptile sidecar review

---------

Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
This commit is contained in:
authored and GitHub committed 2026-07-20 17:49:23 +00:00
1 parent 33d55ec897
commit c414c39f10
7 files changed
+693 -5

No files matched your search

+241
View File
@@ -0,0 +1,241 @@
"""Extension sidecar proxy authentication (token-v1).
A loopback-sidecar extension runs a stdlib HTTP server on 127.0.0.1:<port>. The
browser reaches it ONLY through core's consent-gated proxy
(``/api/extensions/{id}/sidecar/*``). But the loopback port is reachable by any
local process, and the proxy strips every inbound credential before forwarding
(see ``_extension_sidecar_proxy_request_headers`` in ``api/routes.py``), so the
sidecar cannot tell a proxied request from a direct one.
This module mints a per-extension shared secret that core injects on every
forwarded request (header ``X-Hermes-Sidecar-Token``) and the sidecar validates.
It converts "anyone who can send a loopback TCP packet" (other-UID users, host
containers, sandboxed network-only processes) into "processes that can read the
user's state dir" — the same protection level core's own signing key already has
(``.pbkdf2_key`` / ``.signing_key`` are 0600 files in the same directory).
SCOPE (be honest — this is documented in the contract too): the token does NOT
defend against arbitrary same-UID code. A same-user process can read the token,
read core's signing key, or just run the sidecar's underlying tool directly. No
mechanism available here (token, HMAC, nonce, UDS with 0600) changes that.
Design notes (from the reviewed design doc, §9.2):
* Per-extension token file under ``STATE_DIR/sidecar-auth/<ext-id>.token``.
* Unlike ``auth._load_key``, we NEVER return an in-memory-only token: if the
file cannot be persisted+re-read, we report "unavailable" so core fails
closed (503) rather than injecting a secret no sidecar can ever read.
* The token is re-read from disk per request (fingerprint-cached on
inode/mtime/ctime/size) so rotation / deletion takes effect with no restart,
and a stale cached token can't keep validating after the file changes.
* Cross-process mint writes a unique temporary file, then publishes it with
an atomic ``os.link`` create-or-fail operation. Concurrent first-mints
converge on a single winning file; losers re-read the winner rather than
clobbering it.
"""
from __future__ import annotations
import os
import re
import secrets
import threading
from pathlib import Path
from typing import Dict, Optional, Tuple
# Keep this grammar identical to ``_EXTENSION_ID_RE`` in ``api/extensions.py`` —
# a narrower pattern here silently 503s legally-named extensions (uppercase,
# dots, up to 128 chars). The pattern is filesystem-safe as a filename: no path
# separators, cannot start with a dot, so no dotfile / ".."-prefix tricks.
_EXT_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
# Injected/validated tokens are exactly secrets.token_urlsafe output: url-safe
# base64 alphabet. Reject anything else (bounds length, and stops a malformed
# token file's contents from reaching a header where a ValueError could echo it).
_TOKEN_RE = re.compile(r"^[A-Za-z0-9_-]{16,256}$")
_TOKEN_DIR_NAME = "sidecar-auth"
_TOKEN_BYTES = 32 # secrets.token_urlsafe(32) -> ~43 url-safe chars
# Per-request read cache: ext_id -> (token, fingerprint). Guarded by _LOCK.
_LOCK = threading.Lock()
_CACHE: Dict[str, Tuple[str, Tuple]] = {}
def _token_dir() -> Path:
# Resolve STATE_DIR dynamically (mirrors ``_extension_state_dir`` in
# api/extensions.py) so tests and relocated installs see the current dir
# rather than the value cached at import time.
from api.config import STATE_DIR
return STATE_DIR / _TOKEN_DIR_NAME
def _token_path(ext_id: str) -> Path:
return _token_dir() / f"{ext_id}.token"
def _valid_ext_id(ext_id: object) -> bool:
# Use fullmatch (not match) so a trailing newline can't slip through: in
# Python's ``re`` a ``$`` anchor also matches just before a final ``\n``,
# so ``match`` would accept ``"templates\n"`` and _token_path would build a
# filename containing a literal newline. fullmatch anchors truly to the end.
# Validate the exact value that _token_path() uses. Trimming only for the
# check would accept one ID but construct a filename from a different one.
return isinstance(ext_id, str) and bool(_EXT_ID_RE.fullmatch(ext_id))
def _fingerprint(path: Path) -> Optional[Tuple]:
try:
st = path.stat()
except OSError:
return None
return (st.st_ino, st.st_dev, st.st_mtime_ns, st.st_ctime_ns, st.st_size)
def _read_token_file(path: Path) -> Optional[str]:
"""Return the token text iff it is present and well-formed, else None
(fail closed). Only a valid token-v1 shape is ever returned, so malformed
file contents can never reach a header. Thin wrapper over the stable reader."""
tok, _fp = _stable_read(path)
return tok
def _stable_read(path: Path) -> Tuple[Optional[str], Optional[Tuple]]:
"""Read the token together with a fingerprint that is verified to bracket the
read (fingerprint before AND after; retry on any mid-read change). Guarantees
the returned (token, fingerprint) pair reflects a single consistent on-disk
state — never new content under an old stat key, and never a value that
changed underneath us. Returns (None, None) when absent/malformed/racing."""
for _ in range(4): # bounded retry; a file being rotated settles fast
fp_before = _fingerprint(path)
if fp_before is None:
return (None, None)
try:
raw = path.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
return (None, None)
fp_after = _fingerprint(path)
if fp_after != fp_before:
continue # changed mid-read — retry for a consistent snapshot
tok = raw.strip()
if not tok or not _TOKEN_RE.match(tok):
return (None, None)
return (tok, fp_before)
return (None, None) # never settled — fail closed
def ensure_token(ext_id: str) -> Optional[str]:
"""Get-or-create the per-extension token, returning it ONLY if it is durably
persisted and re-readable from disk. Returns None if it can't be persisted
(caller must then treat the sidecar proxy as unavailable / 503) — we never
hand back an ephemeral token a sidecar could never read.
Idempotent + atomic across processes: the token is written to a unique temp
file, then published with an atomic hard-link create-or-fail operation, so
the final path is only ever visible fully written and is never clobbered.
One concurrent writer wins; every loser reads that persisted winner.
"""
if not _valid_ext_id(ext_id):
return None
path = _token_path(ext_id)
existing = _read_token_file(path)
if existing is not None:
return existing
with _LOCK:
existing = _read_token_file(path)
if existing is not None:
return existing
token = secrets.token_urlsafe(_TOKEN_BYTES)
tmp: Optional[Path] = None
try:
d = _token_dir()
d.mkdir(parents=True, exist_ok=True)
try:
os.chmod(d, 0o700)
except OSError:
pass # best-effort dir hardening; file perms are the real guard
# Write to a unique temp, fsync, then publish with atomic
# create-or-fail (os.link, the repo's TOCTOU-safe idiom — see
# session_recovery.py:627). Unlike os.replace, link does NOT clobber:
# if another process already published, ours fails, we drop our temp,
# and everyone reads the single winning file. This closes both the
# empty-file-exposure race AND the cross-process clobber race.
tmp = path.with_name(f".{path.name}.{os.getpid()}.{threading.get_ident()}.tmp")
fd = os.open(str(tmp), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
try:
os.write(fd, token.encode("utf-8"))
os.fsync(fd)
finally:
os.close(fd)
try:
os.link(str(tmp), str(path))
try:
os.chmod(path, 0o600)
except OSError:
pass
except FileExistsError:
pass # another writer won; we return the winner via _stable_read below
finally:
try:
tmp.unlink()
except OSError:
pass
tmp = None
except OSError:
# Persistence failed → report unavailable. Do NOT return the
# in-memory token (the _load_key trap: core would inject a secret the
# sidecar can never read → permanent 401 that looks like a mismatch).
if tmp is not None:
try:
tmp.unlink()
except OSError:
pass
return None
# Return whatever is durably on disk now — under a concurrent mint this
# may be another writer's token, which is correct (all readers converge
# on the winning file). Only a persisted, well-formed token is returned.
persisted, fp = _stable_read(path)
if persisted is not None and fp is not None:
_CACHE[ext_id] = (persisted, fp)
return persisted
def current_token(ext_id: str) -> Optional[str]:
"""Return the current on-disk token for validation/injection, re-reading the
file when its fingerprint changed since last read — so rotation + deletion
take effect immediately and a stale cached token stops validating. Does NOT
mint. Returns None when no token exists (fail closed)."""
if not _valid_ext_id(ext_id):
return None
path = _token_path(ext_id)
fp = _fingerprint(path)
if fp is None:
with _LOCK:
_CACHE.pop(ext_id, None)
return None
with _LOCK:
cached = _CACHE.get(ext_id)
if cached is not None and cached[1] == fp:
return cached[0]
tok, tok_fp = _stable_read(path) # bracketed read: content matches tok_fp
with _LOCK:
if tok is None or tok_fp is None:
_CACHE.pop(ext_id, None)
else:
_CACHE[ext_id] = (tok, tok_fp)
return tok
def reset_token(ext_id: str) -> Optional[str]:
"""Rotate: delete then re-mint. Returns the new token or None on failure.
(Recovery entry point; also usable by a future 'reset token' action.)"""
if not _valid_ext_id(ext_id):
return None
with _LOCK:
_CACHE.pop(ext_id, None)
try:
_token_path(ext_id).unlink()
except FileNotFoundError:
pass
except OSError:
return None
return ensure_token(ext_id)
+110 -2
View File
@@ -898,6 +898,21 @@ def _sidecar_from_manifest_entry(
return None
else:
health_path = _DEFAULT_SIDECAR_HEALTH_PATH
# proxy_auth negotiation (token-v1). Absent = explicit legacy (unauthenticated
# proxy→sidecar, unchanged behavior for any pre-existing declaration). token-v1
# = core mints+injects a per-extension token and the sidecar validates it.
# Any unknown value fails closed (reject the sidecar) so a typo can never
# silently downgrade to unauthenticated.
raw_proxy_auth = raw.get("proxy_auth")
if raw_proxy_auth is None:
proxy_auth = "legacy"
elif raw_proxy_auth in ("legacy", "token-v1"):
proxy_auth = str(raw_proxy_auth)
else:
_add_diagnostic_warning(
diagnostics, "sidecar_proxy_auth_unsupported", _SIDECAR_WARNING_SOURCE
)
return None
sidecar_id = _manifest_entry_text(entry, "id")
name = _manifest_entry_text(entry, "name")
return {
@@ -907,6 +922,7 @@ def _sidecar_from_manifest_entry(
"origin": origin,
"health_path": health_path,
"health_url": f"{origin}{health_path}",
"proxy_auth": proxy_auth,
}
@@ -1159,16 +1175,37 @@ def _sidecar_proxy_public_status(
approved_origin: Optional[str],
*,
available: bool,
proxy_auth: str = "legacy",
) -> Dict[str, Any]:
consented = bool(available and approved_origin == origin)
origin_changed = bool(available and approved_origin and approved_origin != origin)
return {
payload: Dict[str, Any] = {
"available": available,
"consented": consented,
"consent_required": bool(available and not consented),
"path": _extension_sidecar_proxy_path(extension_id),
"origin_changed": origin_changed,
}
# proxy_auth / posture are token-v1-only fields. Legacy sidecars keep the
# original payload shape byte-for-byte (the public status contract tests pin
# it), so a token-v1 negotiation field never leaks onto a legacy record.
# (Frank #6331 blocker 2 — fields were previously emitted on every record and
# broke the legacy exact-shape contract on shard 4.)
if proxy_auth == "token-v1":
# token-v1 posture (§9.1). "protected" = WebUI auth ON (the only posture
# in which token-v1 proxying is permitted; auth-off is now fail-closed at
# both consent and resolution, so "local_unprotected" signals the panel
# to tell the operator to enable authentication before the proxy will work).
posture = None
if available:
try:
from api.auth import is_auth_enabled
posture = "protected" if is_auth_enabled() else "local_unprotected"
except Exception:
posture = None
payload["proxy_auth"] = proxy_auth
payload["posture"] = posture
return payload
def _extension_sidecar_records(
@@ -1216,11 +1253,21 @@ def _extension_sidecar_records(
sidecar["origin"],
item.get("approved_origin"),
available=available,
proxy_auth=sidecar.get("proxy_auth", "legacy"),
)
item["duplicate_id"] = id_counts.get(ext_id, 0) > 1
item["proxy"] = proxy
if len(records) < _MAX_URL_LIST:
records.append({**sidecar, "proxy": proxy})
# Build the PUBLIC sidecar record explicitly. The internal `sidecar`
# dict carries a top-level `proxy_auth` for the consent/resolution
# logic, but the public status contract only exposes proxy_auth (and
# posture) INSIDE `proxy`, and only for token-v1 records — legacy
# sidecars keep their original public shape byte-for-byte. Spreading
# `**sidecar` leaked a top-level `proxy_auth: legacy` and broke the
# exact-shape contract tests (Frank #6331 blocker 2).
public_record = {k: v for k, v in sidecar.items() if k != "proxy_auth"}
public_record["proxy"] = proxy
records.append(public_record)
else:
_add_diagnostic_warning(diagnostics, "sidecar_list_truncated", _SIDECAR_WARNING_SOURCE)
break
@@ -1564,6 +1611,32 @@ def set_extension_sidecar_proxy_consent(extension_id: object, approved: object)
if sidecar is None or proxy.get("available") is not True:
raise ExtensionSidecarProxyError("Extension sidecar proxy is unavailable", status=409)
consent_map[ext_id] = sidecar["origin"]
# For token-v1, mint the per-extension token NOW and fail the consent
# if it can't be persisted+read — otherwise we'd persist consent and
# then 503 on every request (a mint failure must surface at grant
# time, not as a mysterious later error).
if sidecar.get("proxy_auth") == "token-v1":
# token-v1 REQUIRES configured WebUI authentication. Without it,
# this consent endpoint is itself unauthenticated, so any caller
# that can reach the WebUI listener could self-grant consent and
# then drive the token-bearing proxy (a forwarding oracle) — the
# sidecar token file blocks direct port access but does NOT stop a
# caller who simply asks core to inject it. Loopback origin is not
# sufficient: another local UID can still reach the listener
# without reading the 0600 token file. Fail closed regardless of
# origin. (Frank #6331 blocker 1.)
from api.auth import is_auth_enabled
if not is_auth_enabled():
raise ExtensionSidecarProxyError(
"Sidecar token-v1 proxy requires WebUI authentication; "
"set a password in Settings before granting consent.",
status=403,
)
from api import extension_sidecar_auth as _sc_auth
if not _sc_auth.ensure_token(ext_id):
raise ExtensionSidecarProxyError(
"Could not provision the sidecar auth token", status=503
)
else:
consent_map.pop(ext_id, None)
_write_extension_state(
@@ -1625,11 +1698,46 @@ def resolve_extension_sidecar_proxy_target(
upstream_url = f"{sidecar['origin']}{normalized_path}"
if query:
upstream_url = f"{upstream_url}?{query}"
# token-v1 auth (§9.1/§9.2). Legacy sidecars proxy unchanged; token-v1
# sidecars get a per-extension secret injected, and only proxy when the
# trust posture is sound.
proxy_auth = sidecar.get("proxy_auth", "legacy")
inject_token: Optional[str] = None
if proxy_auth == "token-v1":
from api.auth import is_auth_enabled
from api import extension_sidecar_auth as _sc_auth
# token-v1 REQUIRES configured WebUI authentication — fail closed
# regardless of the upstream origin (loopback included). The consent
# endpoint and this resolution path are both unauthenticated when auth is
# off, so a caller that can reach the WebUI listener could otherwise drive
# a token-bearing proxy to the sidecar (a forwarding oracle). Loopback is
# NOT a sufficient guard: another local UID can reach the listener without
# ever reading the 0600 token file. (Frank #6331 blocker 1 — this
# mirrors the consent-time check so a pre-existing consent granted while
# auth was enabled cannot be exercised after auth is turned off.)
if not is_auth_enabled():
raise ExtensionSidecarProxyError(
"Sidecar token-v1 proxy requires WebUI authentication; "
"set a password in Settings.",
status=403,
)
token = _sc_auth.current_token(ext_id) or _sc_auth.ensure_token(ext_id)
if not token:
# Token could not be persisted+read → fail closed rather than proxy
# unauthenticated (never inject an ephemeral secret).
raise ExtensionSidecarProxyError(
"Sidecar proxy auth token is unavailable", status=503
)
inject_token = token
return {
"extension_id": ext_id,
"origin": sidecar["origin"],
"proxy_path": proxy["path"],
"upstream_url": upstream_url,
"proxy_auth": proxy_auth,
"auth_token": inject_token,
}
+14 -2
View File
@@ -5547,6 +5547,7 @@ def _extension_sidecar_proxy_request_headers(handler) -> dict[str, str]:
lower in blocked_headers
or lower in {"authorization", "cookie", "content-length", "host", "origin", "referer"}
or lower.startswith("x-csrf")
or lower.startswith("x-hermes-")
):
continue
headers[str(name)] = str(value)
@@ -5560,7 +5561,11 @@ def _send_extension_sidecar_proxy_response(handler, status: int, body: bytes, he
if headers and hasattr(headers, "items"):
for name, value in headers.items():
lower = str(name).lower()
if lower in blocked_headers or lower in {"content-length", "set-cookie"}:
if (
lower in blocked_headers
or lower in {"content-length", "set-cookie"}
or lower.startswith("x-hermes-")
):
continue
if lower == "content-type":
sent_content_type = True
@@ -5657,10 +5662,17 @@ def _handle_extension_sidecar_proxy(
proxy_path,
query=parsed.query,
)
proxied_headers = _extension_sidecar_proxy_request_headers(handler)
# token-v1: inject the per-extension shared secret core minted. The
# inbound x-hermes-* strip above guarantees the client cannot have
# forged this header.
_auth_token = target.get("auth_token")
if _auth_token:
proxied_headers["X-Hermes-Sidecar-Token"] = _auth_token
request = Request(
target["upstream_url"],
data=request_body,
headers=_extension_sidecar_proxy_request_headers(handler),
headers=proxied_headers,
method=method,
)
opener = _extension_sidecar_proxy_same_origin_opener(target["origin"])
+35 -1
View File
@@ -165,7 +165,8 @@ opt-in proxy:
"sidecar": {
"type": "loopback",
"origin": "http://127.0.0.1:17787",
"health_path": "/health"
"health_path": "/health",
"proxy_auth": "token-v1"
}
}
]
@@ -178,6 +179,39 @@ declared and optionally check its health from the browser. If the operator later
approves the proxy in **Settings → Extensions**, WebUI may proxy requests only
through the fixed per-extension sidecar path for that extension.
### Sidecar proxy authentication (`proxy_auth`)
The loopback port a sidecar binds is reachable by **any local process**, and the
proxy strips every inbound credential (cookies, `Authorization`, CSRF, `x-hermes-*`)
before forwarding — so a sidecar cannot, on its own, tell a proxied request from a
direct one. The `proxy_auth` field closes that gap:
- **`token-v1`** (recommended for any sidecar that mutates state) — WebUI mints a
per-extension secret at `STATE_DIR/sidecar-auth/<id>.token` (mode `0600`) and
injects it as the `X-Hermes-Sidecar-Token` header on every proxied request. The
sidecar resolves the token file in this order — `HERMES_EXT_SIDECAR_TOKEN_FILE`
→ `$HERMES_WEBUI_STATE_DIR/sidecar-auth/<id>.token` →
`$HERMES_HOME/webui/sidecar-auth/<id>.token` → platform default
(`~/.hermes/webui/…`, `%LOCALAPPDATA%\hermes\webui\…` on Windows) — and must
validate the header on every route except `/health`, returning **`401` on a
missing/mismatched token** and **`503` when the token file is absent/unreadable**.
The canonical scaffold in the extensions repository (`examples/`, see
`docs/SIDECAR_CONTRACT.md`) does all of this for you — do not hand-roll it.
- **absent (or the explicit literal `"legacy"`)** — **legacy** mode (no token;
unchanged behavior). Only appropriate for read-only, non-sensitive sidecars.
- Any **other** value fails closed (the sidecar declaration is rejected).
**Auth-off posture:** WebUI authentication is optional and off by default. Because
the consent endpoint and proxy route are unauthenticated in that mode, `token-v1`
fails closed regardless of whether the sidecar origin is loopback: consent and proxy
resolution return `403` until WebUI authentication is configured. Otherwise, any
caller that can reach WebUI could ask core to inject the token and use it as a
forwarding oracle. The extensions panel exposes the `local_unprotected` posture so
the operator is told to enable authentication before granting consent. The token
protects against other-UID and sandboxed local callers; it does **not** defend against
arbitrary same-UID code (which can read the token file, WebUI's own signing key, or
run the sidecar's tool directly).
Extension entries may declare browser-local settings when they also request
extension-owned storage:
+8
View File
@@ -9813,6 +9813,13 @@ function _extensionSidecarCard(sidecars){
const proxyConsentRequired=proxy.consent_required===true;
const proxyOriginChanged=proxy.origin_changed===true;
const proxyPath=(proxy&&proxy.path)||'';
// token-v1 posture: 'local_unprotected' = WebUI auth is off, so proxy consent
// is grantable by any unauthenticated local caller. Warn the operator to
// enable authentication before wiring up a sidecar (design §9.1).
const proxyUnprotected=proxy.posture==='local_unprotected';
const proxyWarning=proxyUnprotected
?`<div class="extension-sidecar-warning">⚠ WebUI authentication is off. This sidecar's proxy consent can be granted by any local process. Set a password in Settings → Password before using sidecar extensions.</div>`
:'';
const proxyStatus=proxyConsented
?'consented'
:(proxyOriginChanged
@@ -9837,6 +9844,7 @@ function _extensionSidecarCard(sidecars){
<div><span>Proxy path</span><code>${esc(proxyPath)}</code></div>
</div>
<div class="extension-sidecar-actions">${proxyButton}</div>
${proxyWarning}
<div class="extension-sidecar-runtime" data-sidecar-runtime-index="${index}" hidden></div>
</div>`;
}).join('')}</div>`:'<div class="extension-url-empty">No loopback sidecars declared.</div>';
+1
View File
@@ -5452,6 +5452,7 @@ main.main > #mainPlugin{display:none;}
.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-warning{margin-top:8px;padding:7px 9px;border:1px solid var(--warning,#d29922);border-radius:6px;background:var(--warning-bg,rgba(210,153,34,0.12));color:var(--warning,#d29922);font-size:11px;line-height:1.4;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;}
+284
View File
@@ -135,6 +135,8 @@ def test_extension_sidecar_proxy_requires_consent_and_reconfirms_after_origin_ch
"origin": "http://127.0.0.1:17787",
"proxy_path": "/api/extensions/templates/sidecar/",
"upstream_url": "http://127.0.0.1:17787/v1/ping?debug=1",
"proxy_auth": "legacy",
"auth_token": None,
}
encoded_target = resolve_extension_sidecar_proxy_target("templates", "v1%2Fprivate")
@@ -963,3 +965,285 @@ def test_extension_sidecar_proxy_consent_route_is_wired(monkeypatch):
"status": 200,
"data": {"ok": True, "id": "templates", "approved": True},
}
def test_extension_sidecar_proxy_consent_route_fails_closed_auth_off(tmp_path, monkeypatch):
# Frank #6331 blocker 1 (route-level regression): with WebUI auth OFF, driving
# the REAL consent function through the HTTP route for a token-v1 sidecar must
# return 403 (fail closed), not persist consent. Uses the real
# set_extension_sidecar_proxy_consent (NOT a mock) so the route + auth gate are
# exercised end-to-end.
from api import routes
monkeypatch.delenv("HERMES_WEBUI_PASSWORD", raising=False)
from api.auth import _invalidate_password_hash_cache, is_auth_enabled
_invalidate_password_hash_cache()
assert is_auth_enabled() is False
_token_v1_manifest(monkeypatch, tmp_path, origin="http://127.0.0.1:17787")
captured = {}
def fake_bad(handler, msg, status=400):
captured["status"] = status
captured["msg"] = msg
return True
def fake_j(handler, data, status=200, headers=None):
captured["status"] = status
captured["data"] = data
return True
monkeypatch.setattr(routes, "_check_csrf", lambda handler: True)
monkeypatch.setattr(routes, "read_body", lambda handler: {"id": "templates", "approved": True})
monkeypatch.setattr(routes, "j", fake_j)
monkeypatch.setattr(routes, "bad", fake_bad)
handler = FakeHandler()
assert routes.handle_post(
handler,
SimpleNamespace(path="/api/extensions/sidecar-proxy-consent"),
) is True
# Must be a fail-closed 403 from the real consent function, not a 200 grant.
assert captured.get("status") == 403
assert "data" not in captured
# And no consent must have been persisted.
from api.extensions import resolve_extension_sidecar_proxy_target, ExtensionSidecarProxyError
with pytest.raises(ExtensionSidecarProxyError) as exc:
resolve_extension_sidecar_proxy_target("templates", "v1/ping")
assert exc.value.status == 403
# ── token-v1 proxy auth (§9.1/§9.2) ─────────────────────────────────────────
def _token_v1_manifest(monkeypatch, tmp_path, origin="http://127.0.0.1:17787"):
return _configure_manifest_extension(
monkeypatch,
tmp_path,
{
"extensions": [
{
"id": "templates",
"sidecar": {
"type": "loopback",
"origin": origin,
"proxy_auth": "token-v1",
},
}
]
},
)
def test_token_v1_injects_persisted_token_when_auth_enabled(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_WEBUI_PASSWORD", "pw")
from api.auth import _invalidate_password_hash_cache
_invalidate_password_hash_cache()
_token_v1_manifest(monkeypatch, tmp_path)
from api.extensions import (
resolve_extension_sidecar_proxy_target,
set_extension_sidecar_proxy_consent,
)
import api.extension_sidecar_auth as sc
set_extension_sidecar_proxy_consent("templates", True)
target = resolve_extension_sidecar_proxy_target("templates", "v1/ping")
assert target["proxy_auth"] == "token-v1"
assert target["auth_token"] and len(target["auth_token"]) > 30
assert target["auth_token"] == sc.current_token("templates")
def test_token_v1_auth_off_blocks_consent_and_resolution_fail_closed(tmp_path, monkeypatch):
# Frank #6331 blocker 1: with WebUI auth OFF, token-v1 must fail closed at BOTH
# the consent grant and the resolution path — regardless of a loopback origin.
# A loopback sidecar is NOT a sufficient guard: any caller that can reach the
# (unauthenticated) WebUI listener could otherwise self-grant consent and drive
# the token-bearing proxy (a forwarding oracle); another local UID can reach the
# listener without ever reading the 0600 token file.
monkeypatch.delenv("HERMES_WEBUI_PASSWORD", raising=False)
from api.auth import _invalidate_password_hash_cache, is_auth_enabled
_invalidate_password_hash_cache()
assert is_auth_enabled() is False
_token_v1_manifest(monkeypatch, tmp_path, origin="http://127.0.0.1:17787")
from api.extensions import (
ExtensionSidecarProxyError,
set_extension_sidecar_proxy_consent,
)
# Consent grant is rejected fail-closed (403) even for a loopback origin.
with pytest.raises(ExtensionSidecarProxyError) as consent_exc:
set_extension_sidecar_proxy_consent("templates", True)
assert consent_exc.value.status == 403
def test_token_v1_resolution_fails_closed_when_auth_disabled_after_consent(tmp_path, monkeypatch):
# Defense in depth: even if a consent record somehow exists (e.g. granted while
# auth was enabled, then auth turned off), the resolution path must ALSO fail
# closed so a stale consent can't be exercised without WebUI auth.
monkeypatch.setenv("HERMES_WEBUI_PASSWORD", "pw")
from api.auth import _invalidate_password_hash_cache, is_auth_enabled
_invalidate_password_hash_cache()
_token_v1_manifest(monkeypatch, tmp_path, origin="http://127.0.0.1:17787")
from api.extensions import (
ExtensionSidecarProxyError,
resolve_extension_sidecar_proxy_target,
set_extension_sidecar_proxy_consent,
)
# Grant consent WHILE auth is enabled (allowed).
assert is_auth_enabled() is True
set_extension_sidecar_proxy_consent("templates", True)
# Now disable auth and confirm resolution refuses the pre-existing consent.
monkeypatch.delenv("HERMES_WEBUI_PASSWORD", raising=False)
_invalidate_password_hash_cache()
assert is_auth_enabled() is False
with pytest.raises(ExtensionSidecarProxyError) as exc:
resolve_extension_sidecar_proxy_target("templates", "v1/ping")
assert exc.value.status == 403
def test_unknown_proxy_auth_value_rejects_sidecar(tmp_path, monkeypatch):
_configure_manifest_extension(
monkeypatch,
tmp_path,
{
"extensions": [
{
"id": "templates",
"sidecar": {
"type": "loopback",
"origin": "http://127.0.0.1:17787",
"proxy_auth": "totally-bogus",
},
}
]
},
)
from api.extensions import (
ExtensionSidecarProxyError,
set_extension_sidecar_proxy_consent,
)
# Unknown proxy_auth -> _sidecar_from_manifest_entry rejects the record, so the
# sidecar never becomes available/consentable (fail closed, not silent-open).
# The rejection surfaces at consent time (the earliest resolve path).
with pytest.raises(ExtensionSidecarProxyError):
set_extension_sidecar_proxy_consent("templates", True)
def test_status_payload_flags_local_unprotected_when_auth_off(tmp_path, monkeypatch):
_token_v1_manifest(monkeypatch, tmp_path)
from api.extensions import get_extension_status
status = get_extension_status()
sidecars = status.get("sidecars") or []
tmpl = next((s for s in sidecars if s.get("id") == "templates"), None)
assert tmpl is not None
proxy = tmpl["proxy"]
assert proxy["proxy_auth"] == "token-v1"
assert proxy["posture"] == "local_unprotected" # auth off -> panel warns pre-consent
def test_inbound_x_hermes_header_is_stripped(monkeypatch):
from api.routes import _extension_sidecar_proxy_request_headers
class H:
headers = {
"X-Hermes-Sidecar-Token": "forged",
"X-Custom": "ok",
"Cookie": "secret",
}
out = _extension_sidecar_proxy_request_headers(H())
assert "X-Custom" in out
assert not any(k.lower().startswith("x-hermes-") for k in out)
assert not any(k.lower() == "cookie" for k in out)
def test_token_module_persists_and_rotates(tmp_path, monkeypatch):
import api.extension_sidecar_auth as sc
# Patch the dynamic dir resolver (mirrors how _extension_state_dir is patched
# elsewhere) so the token lands in an isolated tmp dir, not the shared session
# state dir.
token_dir = tmp_path / "sidecar-auth"
monkeypatch.setattr(sc, "_token_dir", lambda: token_dir)
sc._CACHE.clear()
tok = sc.ensure_token("templates")
assert tok and sc.current_token("templates") == tok
assert (token_dir / "templates.token").exists()
assert sc.current_token("never") is None # fail closed
assert sc.ensure_token("../evil") is None # path-escape rejected
# IDs are filename components. Reject rather than silently trim whitespace so
# validation and _token_path() always operate on the exact same string.
for invalid_id in ("templates\n", " templates", "templates "):
assert sc.ensure_token(invalid_id) is None
assert not (token_dir / f"{invalid_id}.token").exists()
# canonical (wider) id grammar is honored: uppercase/dot ids work
assert sc.ensure_token("RSS.Feeds") is not None
new = sc.reset_token("templates")
assert new and new != tok and sc.current_token("templates") == new
sc._CACHE.clear()
def test_token_v1_route_injects_token_and_strips_response(monkeypatch):
# The 5 most load-bearing lines: the injected token must reach the upstream
# Request, and any x-hermes-* on the response must be stripped before it
# reaches the browser. (Fable coreA item 3.)
from api import routes
captured = {}
class FakeResponse:
def __init__(self):
self.status = 200
self.headers = {
"Content-Type": "application/json",
"X-Hermes-Echo": "leak-me", # must be stripped from client response
}
def read(self, *_a):
return b"{}"
def __enter__(self):
return self
def __exit__(self, *a):
return False
class FakeOpener:
def open(self, request, timeout=10):
captured["headers"] = {k.lower(): v for k, v in request.header_items()}
return FakeResponse()
monkeypatch.setattr(routes, "_check_csrf", lambda handler: True)
monkeypatch.setattr(
"api.extensions.resolve_extension_sidecar_proxy_target",
lambda extension_id, proxy_path, query="": {
"extension_id": extension_id,
"origin": "http://127.0.0.1:17787",
"proxy_path": "/api/extensions/templates/sidecar/",
"upstream_url": "http://127.0.0.1:17787/v1/ping",
"proxy_auth": "token-v1",
"auth_token": "injected-token-abc",
},
)
monkeypatch.setattr(
routes, "_extension_sidecar_proxy_same_origin_opener", lambda o: FakeOpener()
)
handler = FakeHandler()
handler.headers = {
"Accept": "application/json",
"Host": "webui.local",
"Origin": "http://webui.local",
"Referer": "http://webui.local/",
}
result = routes.handle_get(
handler,
SimpleNamespace(path="/api/extensions/templates/sidecar/v1/ping", query=""),
)
assert result is True
# token injected on the way to the sidecar
assert captured["headers"].get("x-hermes-sidecar-token") == "injected-token-abc"
# token/x-hermes header stripped on the way back to the browser
assert handler.header("X-Hermes-Echo") is None