* 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>
1250 lines
41 KiB
Python
1250 lines
41 KiB
Python
"""Focused tests for the extension sidecar proxy contract."""
|
|
|
|
from types import SimpleNamespace
|
|
import io
|
|
import json
|
|
import urllib.request
|
|
|
|
import pytest
|
|
|
|
|
|
class FakeHandler:
|
|
def __init__(self, body: bytes = b""):
|
|
self.status = None
|
|
self.headers = {}
|
|
self.sent_headers = []
|
|
self.body = bytearray()
|
|
self.wfile = self
|
|
self.rfile = io.BytesIO(body)
|
|
|
|
def send_response(self, status):
|
|
self.status = status
|
|
|
|
def send_header(self, name, value):
|
|
self.sent_headers.append((name, value))
|
|
|
|
def end_headers(self):
|
|
pass
|
|
|
|
def write(self, data):
|
|
self.body.extend(data)
|
|
|
|
def header(self, name):
|
|
for key, value in self.sent_headers:
|
|
if key.lower() == name.lower():
|
|
return value
|
|
return None
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _clear_extension_env(monkeypatch):
|
|
from api import auth as auth_mod
|
|
|
|
for name in (
|
|
"HERMES_WEBUI_EXTENSION_DIR",
|
|
"HERMES_WEBUI_EXTENSION_MANIFEST",
|
|
"HERMES_WEBUI_PASSWORD",
|
|
):
|
|
monkeypatch.delenv(name, raising=False)
|
|
auth_mod._invalidate_password_hash_cache()
|
|
yield
|
|
auth_mod._invalidate_password_hash_cache()
|
|
|
|
|
|
def _use_extension_state_dir(monkeypatch, tmp_path):
|
|
tmp_path.mkdir(parents=True, exist_ok=True)
|
|
state_dir = tmp_path / "webui-state"
|
|
state_dir.mkdir()
|
|
monkeypatch.setenv("HERMES_WEBUI_STATE_DIR", str(state_dir))
|
|
import api.extensions as extensions
|
|
|
|
monkeypatch.setattr(extensions, "_extension_state_dir", lambda: state_dir)
|
|
return state_dir
|
|
|
|
|
|
def _write_manifest(root, payload):
|
|
(root / "extensions.json").write_text(json.dumps(payload), encoding="utf-8")
|
|
|
|
|
|
def _configure_manifest_extension(monkeypatch, tmp_path, payload):
|
|
state_dir = _use_extension_state_dir(monkeypatch, tmp_path)
|
|
root = tmp_path / "extensions"
|
|
root.mkdir(parents=True, exist_ok=True)
|
|
_write_manifest(root, payload)
|
|
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root))
|
|
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_MANIFEST", "extensions.json")
|
|
return state_dir, root
|
|
|
|
|
|
def test_extension_sidecar_proxy_requires_webui_auth(monkeypatch):
|
|
monkeypatch.setenv("HERMES_WEBUI_PASSWORD", "test-password")
|
|
|
|
from api.auth import check_auth
|
|
|
|
handler = FakeHandler()
|
|
assert check_auth(
|
|
handler,
|
|
SimpleNamespace(path="/api/extensions/templates/sidecar/health", query=""),
|
|
) is False
|
|
assert handler.status == 401
|
|
assert handler.header("Location") is None
|
|
|
|
|
|
def test_extension_sidecar_proxy_requires_consent_and_reconfirms_after_origin_change(
|
|
tmp_path, monkeypatch
|
|
):
|
|
state_dir, root = _configure_manifest_extension(
|
|
monkeypatch,
|
|
tmp_path,
|
|
{
|
|
"extensions": [
|
|
{
|
|
"id": "templates",
|
|
"sidecar": {
|
|
"type": "loopback",
|
|
"origin": "http://127.0.0.1:17787",
|
|
},
|
|
}
|
|
]
|
|
},
|
|
)
|
|
|
|
from api.extensions import (
|
|
ExtensionSidecarProxyError,
|
|
resolve_extension_sidecar_proxy_target,
|
|
set_extension_sidecar_proxy_consent,
|
|
)
|
|
|
|
with pytest.raises(ExtensionSidecarProxyError) as unapproved:
|
|
resolve_extension_sidecar_proxy_target("templates", "v1/ping", "debug=1")
|
|
assert unapproved.value.status == 403
|
|
|
|
approved = set_extension_sidecar_proxy_consent("templates", True)
|
|
assert approved["sidecars"][0]["proxy"]["consented"] is True
|
|
assert json.loads((state_dir / "extension-overrides.json").read_text(encoding="utf-8")) == {
|
|
"version": 1,
|
|
"disabled_extensions": [],
|
|
"sidecar_proxy_consents": {
|
|
"templates": "http://127.0.0.1:17787",
|
|
},
|
|
}
|
|
|
|
target = resolve_extension_sidecar_proxy_target("templates", "v1/ping", "debug=1")
|
|
assert target == {
|
|
"extension_id": "templates",
|
|
"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")
|
|
assert encoded_target["upstream_url"] == "http://127.0.0.1:17787/v1%2Fprivate"
|
|
|
|
_write_manifest(
|
|
root,
|
|
{
|
|
"extensions": [
|
|
{
|
|
"id": "templates",
|
|
"sidecar": {
|
|
"type": "loopback",
|
|
"origin": "http://127.0.0.1:17788",
|
|
},
|
|
}
|
|
]
|
|
},
|
|
)
|
|
changed = set_extension_sidecar_proxy_consent("templates", False)
|
|
assert changed["sidecars"][0]["proxy"]["origin_changed"] is False
|
|
with pytest.raises(ExtensionSidecarProxyError) as changed_origin:
|
|
resolve_extension_sidecar_proxy_target("templates", "v1/ping")
|
|
assert changed_origin.value.status == 403
|
|
|
|
|
|
def test_extension_sidecar_proxy_rejects_unavailable_surfaces(tmp_path, monkeypatch):
|
|
from api.extensions import ExtensionSidecarProxyError, resolve_extension_sidecar_proxy_target
|
|
|
|
_configure_manifest_extension(
|
|
monkeypatch,
|
|
tmp_path / "duplicate",
|
|
{
|
|
"extensions": [
|
|
{
|
|
"id": "templates",
|
|
"sidecar": {
|
|
"type": "loopback",
|
|
"origin": "http://127.0.0.1:17787",
|
|
},
|
|
},
|
|
{
|
|
"id": "templates",
|
|
"sidecar": {
|
|
"type": "loopback",
|
|
"origin": "http://127.0.0.1:17788",
|
|
},
|
|
},
|
|
]
|
|
},
|
|
)
|
|
with pytest.raises(ExtensionSidecarProxyError) as duplicate:
|
|
resolve_extension_sidecar_proxy_target("templates", "v1/ping")
|
|
assert duplicate.value.status == 409
|
|
|
|
_configure_manifest_extension(
|
|
monkeypatch,
|
|
tmp_path / "manifest_disabled",
|
|
{
|
|
"extensions": [
|
|
{
|
|
"id": "templates",
|
|
"enabled": False,
|
|
"sidecar": {
|
|
"type": "loopback",
|
|
"origin": "http://127.0.0.1:17787",
|
|
},
|
|
}
|
|
]
|
|
},
|
|
)
|
|
with pytest.raises(ExtensionSidecarProxyError) as manifest_disabled:
|
|
resolve_extension_sidecar_proxy_target("templates", "v1/ping")
|
|
assert manifest_disabled.value.status == 409
|
|
|
|
state_dir, _root = _configure_manifest_extension(
|
|
monkeypatch,
|
|
tmp_path / "user_disabled",
|
|
{
|
|
"extensions": [
|
|
{
|
|
"id": "templates",
|
|
"sidecar": {
|
|
"type": "loopback",
|
|
"origin": "http://127.0.0.1:17787",
|
|
},
|
|
}
|
|
]
|
|
},
|
|
)
|
|
(state_dir / "extension-overrides.json").write_text(
|
|
json.dumps(
|
|
{
|
|
"version": 1,
|
|
"disabled_extensions": ["templates"],
|
|
"sidecar_proxy_consents": {
|
|
"templates": "http://127.0.0.1:17787",
|
|
},
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
with pytest.raises(ExtensionSidecarProxyError) as user_disabled:
|
|
resolve_extension_sidecar_proxy_target("templates", "v1/ping")
|
|
assert user_disabled.value.status == 409
|
|
|
|
_configure_manifest_extension(
|
|
monkeypatch,
|
|
tmp_path / "unsupported",
|
|
{
|
|
"extensions": [
|
|
{
|
|
"id": "templates",
|
|
"sidecar": {
|
|
"type": "unix-socket",
|
|
"origin": "http://127.0.0.1:17787",
|
|
},
|
|
}
|
|
]
|
|
},
|
|
)
|
|
with pytest.raises(ExtensionSidecarProxyError) as unsupported:
|
|
resolve_extension_sidecar_proxy_target("templates", "v1/ping")
|
|
assert unsupported.value.status == 409
|
|
|
|
|
|
def test_extension_sidecar_proxy_malformed_consents_fail_closed(tmp_path, monkeypatch):
|
|
from api.extensions import ExtensionSidecarProxyError, resolve_extension_sidecar_proxy_target
|
|
|
|
state_dir, _root = _configure_manifest_extension(
|
|
monkeypatch,
|
|
tmp_path / "malformed_consents",
|
|
{
|
|
"extensions": [
|
|
{
|
|
"id": "templates",
|
|
"sidecar": {
|
|
"type": "loopback",
|
|
"origin": "http://127.0.0.1:17787",
|
|
},
|
|
}
|
|
]
|
|
},
|
|
)
|
|
(state_dir / "extension-overrides.json").write_text(
|
|
json.dumps(
|
|
{
|
|
"version": 1,
|
|
"disabled_extensions": [],
|
|
"sidecar_proxy_consents": {
|
|
"templates": "http://127.0.0.1:17787",
|
|
"../bad": "http://127.0.0.1:17788",
|
|
},
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
with pytest.raises(ExtensionSidecarProxyError) as exc:
|
|
resolve_extension_sidecar_proxy_target("templates", "v1/ping")
|
|
assert exc.value.status == 403
|
|
|
|
|
|
def test_extension_sidecar_proxy_route_uses_shared_resolver_and_strips_headers(monkeypatch):
|
|
from api import routes
|
|
|
|
captured = {}
|
|
|
|
class FakeResponse:
|
|
def __init__(self):
|
|
self.status = 202
|
|
self.headers = {
|
|
"Content-Type": "application/json",
|
|
"Set-Cookie": "sidecar=1",
|
|
"Connection": "close, X-Upstream-Hop",
|
|
"X-Sidecar": "ok",
|
|
"X-Upstream-Hop": "strip-me",
|
|
}
|
|
|
|
def read(self, *_args):
|
|
return b'{"ok":true}'
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc, tb):
|
|
return False
|
|
|
|
class FakeOpener:
|
|
def open(self, request, timeout=10):
|
|
captured["url"] = request.full_url
|
|
captured["method"] = request.get_method()
|
|
captured["data"] = request.data
|
|
captured["headers"] = {k.lower(): v for k, v in request.header_items()}
|
|
captured["timeout"] = timeout
|
|
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": f"http://127.0.0.1:17787/{proxy_path}?{query}",
|
|
},
|
|
)
|
|
monkeypatch.setattr(
|
|
routes,
|
|
"_extension_sidecar_proxy_same_origin_opener",
|
|
lambda allowed_origin: FakeOpener(),
|
|
)
|
|
|
|
raw_body = b'{"ping":"pong"}'
|
|
handler = FakeHandler(raw_body)
|
|
handler.headers = {
|
|
"Accept": "application/json",
|
|
"If-None-Match": '"abc123"',
|
|
"Range": "bytes=0-64",
|
|
"Content-Type": "application/json",
|
|
"Content-Length": str(len(raw_body)),
|
|
"Cookie": "webui=secret",
|
|
"Authorization": "Bearer secret",
|
|
"Host": "webui.local",
|
|
"Origin": "http://webui.local",
|
|
"Referer": "http://webui.local/settings",
|
|
"X-CSRF-Token": "secret",
|
|
"X-Sidecar-Auth": "local-token",
|
|
"Connection": "keep-alive, X-Client-Hop",
|
|
"X-Client-Hop": "strip-me",
|
|
}
|
|
|
|
result = routes.handle_post(
|
|
handler,
|
|
SimpleNamespace(path="/api/extensions/templates/sidecar/v1/ping", query="debug=1"),
|
|
)
|
|
assert result is True
|
|
assert captured == {
|
|
"url": "http://127.0.0.1:17787/v1/ping?debug=1",
|
|
"method": "POST",
|
|
"data": raw_body,
|
|
"headers": {
|
|
"accept": "application/json",
|
|
"content-type": "application/json",
|
|
"if-none-match": '"abc123"',
|
|
"range": "bytes=0-64",
|
|
"x-sidecar-auth": "local-token",
|
|
},
|
|
"timeout": 10,
|
|
}
|
|
assert handler.status == 202
|
|
assert handler.body == b'{"ok":true}'
|
|
assert handler.header("Content-Type") == "application/json"
|
|
assert handler.header("X-Sidecar") == "ok"
|
|
assert handler.header("Set-Cookie") is None
|
|
assert handler.header("Connection") is None
|
|
assert handler.header("X-Upstream-Hop") is None
|
|
|
|
|
|
def test_extension_sidecar_proxy_get_rejects_cross_site_browser_request(monkeypatch):
|
|
from api import routes
|
|
|
|
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",
|
|
},
|
|
)
|
|
|
|
handler = FakeHandler()
|
|
handler.headers = {
|
|
"Origin": "https://evil.example",
|
|
"Host": "webui.local",
|
|
"Sec-Fetch-Site": "cross-site",
|
|
}
|
|
|
|
result = routes.handle_get(
|
|
handler,
|
|
SimpleNamespace(path="/api/extensions/templates/sidecar/v1/ping", query=""),
|
|
)
|
|
assert result is None
|
|
assert handler.status == 403
|
|
assert json.loads(handler.body.decode("utf-8")) == {
|
|
"error": "Cross-origin mismatch - check reverse proxy headers"
|
|
}
|
|
|
|
|
|
def test_extension_sidecar_proxy_get_requires_browser_provenance(monkeypatch):
|
|
from api import routes
|
|
|
|
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",
|
|
},
|
|
)
|
|
|
|
handler = FakeHandler()
|
|
result = routes.handle_get(
|
|
handler,
|
|
SimpleNamespace(path="/api/extensions/templates/sidecar/v1/ping", query=""),
|
|
)
|
|
assert result is None
|
|
assert handler.status == 403
|
|
assert json.loads(handler.body.decode("utf-8")) == {
|
|
"error": "Cross-origin mismatch - check reverse proxy headers"
|
|
}
|
|
|
|
|
|
def test_extension_sidecar_proxy_post_requires_browser_provenance(monkeypatch):
|
|
"""Regression (#5228 gate): unsafe methods must require the same browser
|
|
provenance as GET. Before the fix only GET enforced require_provenance, so a
|
|
headerless (non-browser) POST fell through the CSRF compatibility path that
|
|
intentionally admits Origin/Referer-less clients — giving POST/PATCH/PUT/
|
|
DELETE weaker provenance than GET on the loopback proxy route."""
|
|
from api import routes
|
|
|
|
# If provenance were NOT enforced, the resolver/opener would be reached; make
|
|
# them explode so any regression that lets a headerless POST through fails
|
|
# loudly instead of silently proxying.
|
|
def _boom(*_args, **_kwargs):
|
|
raise AssertionError("headerless POST must be rejected before proxying")
|
|
|
|
monkeypatch.setattr("api.extensions.resolve_extension_sidecar_proxy_target", _boom)
|
|
monkeypatch.setattr(
|
|
routes, "_extension_sidecar_proxy_same_origin_opener", _boom
|
|
)
|
|
|
|
handler = FakeHandler(b'{"ping":"pong"}')
|
|
# No Origin / Referer / Sec-Fetch-Site — a non-browser client.
|
|
handler.headers = {"Content-Type": "application/json", "Content-Length": "15"}
|
|
|
|
result = routes.handle_post(
|
|
handler,
|
|
SimpleNamespace(path="/api/extensions/templates/sidecar/v1/ping", query=""),
|
|
)
|
|
assert result is None
|
|
assert handler.status == 403
|
|
assert json.loads(handler.body.decode("utf-8")) == {
|
|
"error": "Cross-origin mismatch - check reverse proxy headers"
|
|
}
|
|
|
|
|
|
def test_extension_sidecar_proxy_get_allows_same_origin_browser_request_without_csrf_token(monkeypatch):
|
|
from api import routes
|
|
|
|
class FakeResponse:
|
|
def __init__(self):
|
|
self.status = 200
|
|
self.headers = {"Content-Type": "application/json"}
|
|
|
|
def read(self, *_args):
|
|
return b'{"ok":true}'
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc, tb):
|
|
return False
|
|
|
|
class FakeOpener:
|
|
def open(self, request, timeout=10):
|
|
return FakeResponse()
|
|
|
|
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",
|
|
},
|
|
)
|
|
monkeypatch.setattr(
|
|
routes,
|
|
"_extension_sidecar_proxy_same_origin_opener",
|
|
lambda allowed_origin: FakeOpener(),
|
|
)
|
|
|
|
handler = FakeHandler()
|
|
handler.headers = {
|
|
"Origin": "http://webui.local",
|
|
"Host": "webui.local",
|
|
"Sec-Fetch-Site": "same-origin",
|
|
}
|
|
|
|
result = routes.handle_get(
|
|
handler,
|
|
SimpleNamespace(path="/api/extensions/templates/sidecar/v1/ping", query=""),
|
|
)
|
|
assert result is True
|
|
assert handler.status == 200
|
|
assert json.loads(handler.body.decode("utf-8")) == {"ok": True}
|
|
|
|
|
|
def test_extension_sidecar_proxy_route_preserves_upstream_http_errors(monkeypatch):
|
|
from api import routes
|
|
from urllib.error import HTTPError
|
|
|
|
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",
|
|
},
|
|
)
|
|
|
|
class ErrorHeaders(dict):
|
|
pass
|
|
|
|
error = HTTPError(
|
|
"http://127.0.0.1:17787/v1/ping",
|
|
418,
|
|
"teapot",
|
|
ErrorHeaders({"Content-Type": "text/plain", "Set-Cookie": "drop=1"}),
|
|
io.BytesIO(b"sidecar said no"),
|
|
)
|
|
|
|
class FakeOpener:
|
|
def open(self, request, timeout=10):
|
|
raise error
|
|
|
|
monkeypatch.setattr(
|
|
routes,
|
|
"_extension_sidecar_proxy_same_origin_opener",
|
|
lambda allowed_origin: FakeOpener(),
|
|
)
|
|
|
|
handler = FakeHandler()
|
|
handler.headers = {
|
|
"Origin": "http://webui.local",
|
|
"Host": "webui.local",
|
|
"Sec-Fetch-Site": "same-origin",
|
|
}
|
|
result = routes.handle_get(
|
|
handler,
|
|
SimpleNamespace(path="/api/extensions/templates/sidecar/v1/ping", query=""),
|
|
)
|
|
assert result is True
|
|
assert handler.status == 418
|
|
assert handler.body == b"sidecar said no"
|
|
assert handler.header("Content-Type") == "text/plain"
|
|
assert handler.header("Set-Cookie") is None
|
|
|
|
|
|
def test_extension_sidecar_proxy_get_rejects_sec_fetch_same_origin_without_origin(monkeypatch):
|
|
from api import routes
|
|
|
|
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",
|
|
},
|
|
)
|
|
|
|
handler = FakeHandler()
|
|
handler.headers = {
|
|
"Host": "webui.local",
|
|
"Sec-Fetch-Site": "same-origin",
|
|
}
|
|
|
|
result = routes.handle_get(
|
|
handler,
|
|
SimpleNamespace(path="/api/extensions/templates/sidecar/v1/ping", query=""),
|
|
)
|
|
assert result is None
|
|
assert handler.status == 403
|
|
assert json.loads(handler.body.decode("utf-8")) == {
|
|
"error": "Cross-origin mismatch - check reverse proxy headers"
|
|
}
|
|
|
|
|
|
def test_extension_sidecar_proxy_get_allows_top_level_navigation_provenance(monkeypatch):
|
|
from api import routes
|
|
|
|
class FakeResponse:
|
|
def __init__(self):
|
|
self.status = 200
|
|
self.headers = {"Content-Type": "application/json"}
|
|
|
|
def read(self, *_args):
|
|
return b'{"ok":true}'
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc, tb):
|
|
return False
|
|
|
|
class FakeOpener:
|
|
def open(self, request, timeout=10):
|
|
return FakeResponse()
|
|
|
|
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",
|
|
},
|
|
)
|
|
monkeypatch.setattr(
|
|
routes,
|
|
"_extension_sidecar_proxy_same_origin_opener",
|
|
lambda allowed_origin: FakeOpener(),
|
|
)
|
|
|
|
handler = FakeHandler()
|
|
handler.headers = {
|
|
"Host": "webui.local",
|
|
"Sec-Fetch-Site": "none",
|
|
}
|
|
|
|
result = routes.handle_get(
|
|
handler,
|
|
SimpleNamespace(path="/api/extensions/templates/sidecar/v1/ping", query=""),
|
|
)
|
|
assert result is True
|
|
assert handler.status == 200
|
|
assert json.loads(handler.body.decode("utf-8")) == {"ok": True}
|
|
|
|
|
|
def test_extension_sidecar_proxy_route_rejects_oversized_upstream_response(monkeypatch):
|
|
from api import routes
|
|
|
|
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",
|
|
},
|
|
)
|
|
|
|
class FakeResponse:
|
|
def __init__(self):
|
|
self.status = 200
|
|
self.headers = {"Content-Type": "application/octet-stream"}
|
|
|
|
def read(self, size=-1):
|
|
assert size == routes._EXTENSION_SIDECAR_PROXY_MAX_RESPONSE_BYTES + 1
|
|
return b"x" * size
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc, tb):
|
|
return False
|
|
|
|
class FakeOpener:
|
|
def open(self, request, timeout=10):
|
|
return FakeResponse()
|
|
|
|
monkeypatch.setattr(
|
|
routes,
|
|
"_extension_sidecar_proxy_same_origin_opener",
|
|
lambda allowed_origin: FakeOpener(),
|
|
)
|
|
|
|
handler = FakeHandler()
|
|
handler.headers = {
|
|
"Origin": "http://webui.local",
|
|
"Host": "webui.local",
|
|
"Sec-Fetch-Site": "same-origin",
|
|
}
|
|
|
|
result = routes.handle_get(
|
|
handler,
|
|
SimpleNamespace(path="/api/extensions/templates/sidecar/v1/ping", query=""),
|
|
)
|
|
assert result is None
|
|
assert handler.status == 502
|
|
assert json.loads(handler.body.decode("utf-8")) == {
|
|
"error": "Extension sidecar response too large"
|
|
}
|
|
|
|
|
|
def test_extension_sidecar_proxy_route_rejects_oversized_upstream_http_error(monkeypatch):
|
|
from api import routes
|
|
from urllib.error import HTTPError
|
|
|
|
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",
|
|
},
|
|
)
|
|
|
|
class OversizedBody(io.BytesIO):
|
|
def read(self, size=-1):
|
|
assert size == routes._EXTENSION_SIDECAR_PROXY_MAX_RESPONSE_BYTES + 1
|
|
return b"x" * size
|
|
|
|
error = HTTPError(
|
|
"http://127.0.0.1:17787/v1/ping",
|
|
502,
|
|
"bad gateway",
|
|
{},
|
|
OversizedBody(),
|
|
)
|
|
|
|
class FakeOpener:
|
|
def open(self, request, timeout=10):
|
|
raise error
|
|
|
|
monkeypatch.setattr(
|
|
routes,
|
|
"_extension_sidecar_proxy_same_origin_opener",
|
|
lambda allowed_origin: FakeOpener(),
|
|
)
|
|
|
|
handler = FakeHandler()
|
|
handler.headers = {
|
|
"Origin": "http://webui.local",
|
|
"Host": "webui.local",
|
|
"Sec-Fetch-Site": "same-origin",
|
|
}
|
|
|
|
result = routes.handle_get(
|
|
handler,
|
|
SimpleNamespace(path="/api/extensions/templates/sidecar/v1/ping", query=""),
|
|
)
|
|
assert result is None
|
|
assert handler.status == 502
|
|
assert json.loads(handler.body.decode("utf-8")) == {
|
|
"error": "Extension sidecar response too large"
|
|
}
|
|
|
|
|
|
def test_extension_sidecar_proxy_route_returns_sanitized_502(monkeypatch):
|
|
from api import routes
|
|
|
|
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",
|
|
},
|
|
)
|
|
class FakeOpener:
|
|
def open(self, request, timeout=10):
|
|
raise OSError("no route")
|
|
|
|
monkeypatch.setattr(
|
|
routes,
|
|
"_extension_sidecar_proxy_same_origin_opener",
|
|
lambda allowed_origin: FakeOpener(),
|
|
)
|
|
|
|
handler = FakeHandler()
|
|
handler.headers = {
|
|
"Origin": "http://webui.local",
|
|
"Host": "webui.local",
|
|
"Sec-Fetch-Site": "same-origin",
|
|
}
|
|
result = routes.handle_get(
|
|
handler,
|
|
SimpleNamespace(path="/api/extensions/templates/sidecar/v1/ping", query=""),
|
|
)
|
|
assert result is None
|
|
assert handler.status == 502
|
|
assert json.loads(handler.body.decode("utf-8")) == {
|
|
"error": "Failed to reach extension sidecar"
|
|
}
|
|
|
|
|
|
def test_extension_sidecar_proxy_redirect_guard_preserves_origin_only():
|
|
from api import routes
|
|
|
|
assert routes._extension_sidecar_proxy_redirect_url(
|
|
"http://127.0.0.1:17787",
|
|
"http://127.0.0.1:17787/v1/ping",
|
|
"/v1/next?debug=1",
|
|
) == "http://127.0.0.1:17787/v1/next?debug=1"
|
|
assert routes._extension_sidecar_proxy_redirect_url(
|
|
"http://127.0.0.1:17787",
|
|
"http://127.0.0.1:17787/v1/ping",
|
|
"http://evil.example/steal",
|
|
) is None
|
|
assert routes._extension_sidecar_proxy_redirect_url(
|
|
"http://127.0.0.1:17787",
|
|
"http://127.0.0.1:17787/v1/ping",
|
|
"http://127.0.0.1:17788/other-port",
|
|
) is None
|
|
assert routes._extension_sidecar_proxy_redirect_url(
|
|
"http://localhost",
|
|
"http://localhost/v1/ping",
|
|
"http://LOCALHOST:80/v1/next",
|
|
) == "http://LOCALHOST:80/v1/next"
|
|
assert routes._extension_sidecar_proxy_redirect_url(
|
|
"https://localhost:443",
|
|
"https://localhost/v1/ping",
|
|
"https://localhost/v1/next",
|
|
) == "https://localhost/v1/next"
|
|
|
|
|
|
def test_extension_sidecar_proxy_route_uses_same_origin_redirect_opener(monkeypatch):
|
|
from api import routes
|
|
|
|
captured = {}
|
|
|
|
class FakeResponse:
|
|
status = 200
|
|
headers = {"Content-Type": "application/json"}
|
|
|
|
def read(self, *_args):
|
|
return b'{"ok":true}'
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc, tb):
|
|
return False
|
|
|
|
class FakeOpener:
|
|
def open(self, request, timeout=10):
|
|
captured["url"] = request.full_url
|
|
captured["timeout"] = timeout
|
|
return FakeResponse()
|
|
|
|
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",
|
|
},
|
|
)
|
|
monkeypatch.setattr(
|
|
routes,
|
|
"_extension_sidecar_proxy_same_origin_opener",
|
|
lambda allowed_origin: (
|
|
captured.__setitem__("allowed_origin", allowed_origin),
|
|
FakeOpener(),
|
|
)[1],
|
|
)
|
|
|
|
handler = FakeHandler()
|
|
handler.headers = {
|
|
"Origin": "http://webui.local",
|
|
"Host": "webui.local",
|
|
"Sec-Fetch-Site": "same-origin",
|
|
}
|
|
result = routes.handle_get(
|
|
handler,
|
|
SimpleNamespace(path="/api/extensions/templates/sidecar/v1/ping", query=""),
|
|
)
|
|
assert result is True
|
|
assert captured == {
|
|
"allowed_origin": "http://127.0.0.1:17787",
|
|
"url": "http://127.0.0.1:17787/v1/ping",
|
|
"timeout": 10,
|
|
}
|
|
assert handler.status == 200
|
|
assert handler.body == b'{"ok":true}'
|
|
|
|
|
|
def test_extension_sidecar_proxy_opener_disables_ambient_proxies(monkeypatch):
|
|
from api import routes
|
|
|
|
captured = {}
|
|
|
|
def fake_build_opener(*handlers):
|
|
captured["handlers"] = handlers
|
|
return object()
|
|
|
|
monkeypatch.setattr(routes, "build_opener", fake_build_opener)
|
|
opener = routes._extension_sidecar_proxy_same_origin_opener("http://127.0.0.1:17787")
|
|
assert opener is not None
|
|
proxy_handlers = [
|
|
handler
|
|
for handler in captured["handlers"]
|
|
if isinstance(handler, urllib.request.ProxyHandler)
|
|
]
|
|
assert len(proxy_handlers) == 1
|
|
assert proxy_handlers[0].proxies == {}
|
|
|
|
|
|
def test_extension_sidecar_proxy_consent_route_is_wired(monkeypatch):
|
|
from api import routes
|
|
|
|
captured = {}
|
|
|
|
def fake_j(handler, data, status=200, headers=None):
|
|
captured["data"] = data
|
|
captured["status"] = status
|
|
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(
|
|
"api.extensions.set_extension_sidecar_proxy_consent",
|
|
lambda extension_id, approved: {
|
|
"ok": True,
|
|
"id": extension_id,
|
|
"approved": approved,
|
|
},
|
|
)
|
|
handler = FakeHandler()
|
|
|
|
assert routes.handle_post(
|
|
handler,
|
|
SimpleNamespace(path="/api/extensions/sidecar-proxy-consent"),
|
|
) is True
|
|
assert captured == {
|
|
"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
|