Merge origin/main into bb/gui
This commit is contained in:
@@ -520,12 +520,6 @@ class TestBuildSchemaFromConfig:
|
||||
assert entry["type"] == "select"
|
||||
assert "options" in entry
|
||||
assert "local" in entry["options"]
|
||||
assert "vercel_sandbox" in entry["options"]
|
||||
runtime_entry = CONFIG_SCHEMA["terminal.vercel_runtime"]
|
||||
assert runtime_entry["type"] == "select"
|
||||
assert "node24" in runtime_entry["options"]
|
||||
assert "python3.13" in runtime_entry["options"]
|
||||
assert len(runtime_entry["options"]) >= 3
|
||||
|
||||
def test_empty_prefix_produces_correct_keys(self):
|
||||
from hermes_cli.web_server import _build_schema_from_config
|
||||
@@ -2538,3 +2532,78 @@ def test_resolve_chat_argv_injects_gateway_ws_url(monkeypatch):
|
||||
gateway_url = env.get("HERMES_TUI_GATEWAY_URL", "")
|
||||
assert gateway_url.startswith("ws://127.0.0.1:9119/api/ws?")
|
||||
assert "token=" in gateway_url
|
||||
|
||||
|
||||
class TestDashboardPluginStaticAssetAllowlist:
|
||||
"""``/dashboard-plugins/<name>/<path>`` is unauthenticated by design —
|
||||
the SPA loads plugin JS via ``<script src>`` and CSS via
|
||||
``<link href>``, neither of which can attach a custom auth header.
|
||||
Instead the route restricts file types to the browser-asset
|
||||
allowlist (JS/CSS/JSON/images/fonts) so that user-installed
|
||||
plugins shipping a ``plugin_api.py`` backend module don't leak
|
||||
their Python source to anyone reachable on the loopback port.
|
||||
|
||||
Regression test for the dashboard pentest finding filed alongside
|
||||
the ``web-pentest`` skill (PR #32265 / issue #32267).
|
||||
"""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup_test_client(self, monkeypatch, _isolate_hermes_home):
|
||||
try:
|
||||
from starlette.testclient import TestClient
|
||||
except ImportError:
|
||||
pytest.skip("fastapi/starlette not installed")
|
||||
|
||||
from hermes_cli.web_server import app
|
||||
|
||||
self.client = TestClient(app)
|
||||
|
||||
def test_python_source_is_404(self):
|
||||
"""The example plugin's ``plugin_api.py`` must NOT be served as
|
||||
a static asset, even though the file exists under the plugin's
|
||||
dashboard directory. Suffix not in the allowlist → 404."""
|
||||
resp = self.client.get("/dashboard-plugins/example/plugin_api.py")
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_pycache_is_404(self):
|
||||
"""Same protection for compiled Python (``.pyc``) inside the
|
||||
plugin's ``__pycache__/``. Real plugins ship these as a
|
||||
side-effect of running tests / dashboard once."""
|
||||
# __pycache__ files are only generated after the api file has
|
||||
# been imported once. Use the path the example plugin actually
|
||||
# generates during the dashboard test boot.
|
||||
resp = self.client.get(
|
||||
"/dashboard-plugins/example/__pycache__/plugin_api.cpython-311.pyc"
|
||||
)
|
||||
# 404 either way (file may not exist on this CI Python version);
|
||||
# what matters is we never get a 200 with the bytes.
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_manifest_json_still_served(self):
|
||||
"""JSON files remain browser-fetchable — manifests, localized
|
||||
data, source maps, etc. all sit in this bucket."""
|
||||
resp = self.client.get("/dashboard-plugins/example/manifest.json")
|
||||
assert resp.status_code == 200
|
||||
assert resp.headers["content-type"].startswith("application/json")
|
||||
# And the body is actually the manifest, not the SPA fallback.
|
||||
body = resp.json()
|
||||
assert body.get("name") == "example"
|
||||
|
||||
def test_unknown_plugin_is_404(self):
|
||||
"""Existing behaviour preserved: nonexistent plugin name → 404."""
|
||||
resp = self.client.get(
|
||||
"/dashboard-plugins/_definitely_not_a_plugin_/manifest.json"
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_path_traversal_still_blocked(self):
|
||||
"""The allowlist is on top of the existing ``.resolve()`` /
|
||||
``is_relative_to()`` check — a ``.js`` named file at an
|
||||
out-of-base path is still rejected as traversal, not served."""
|
||||
resp = self.client.get(
|
||||
"/dashboard-plugins/example/..%2Fplugin_api.py"
|
||||
)
|
||||
# 403 traversal-blocked OR 404 (depending on URL decode order)
|
||||
# — never 200.
|
||||
assert resp.status_code in (403, 404)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user