"""Tests for opt-in WebUI extension hooks. The extension surface must stay deliberately small and safe: - disabled unless configured by environment - same-origin script/style URLs only - no filesystem path leakage in public config - static assets sandboxed to the configured extension directory """ import logging from types import SimpleNamespace import pytest @pytest.fixture(autouse=True) def _clear_extension_env(monkeypatch): for name in ( "HERMES_WEBUI_EXTENSION_DIR", "HERMES_WEBUI_EXTENSION_MANIFEST", "HERMES_WEBUI_EXTENSION_SCRIPT_URLS", "HERMES_WEBUI_EXTENSION_STYLESHEET_URLS", ): monkeypatch.delenv(name, raising=False) class FakeHandler: def __init__(self): self.status = None self.headers = {} self.sent_headers = [] self.body = bytearray() self.wfile = self 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 def test_extension_config_disabled_by_default(monkeypatch): monkeypatch.delenv("HERMES_WEBUI_EXTENSION_DIR", raising=False) monkeypatch.delenv("HERMES_WEBUI_EXTENSION_SCRIPT_URLS", raising=False) monkeypatch.delenv("HERMES_WEBUI_EXTENSION_STYLESHEET_URLS", raising=False) from api.extensions import get_extension_config assert get_extension_config() == { "enabled": False, "script_urls": [], "stylesheet_urls": [], } def test_extension_config_accepts_only_safe_same_origin_urls(tmp_path, monkeypatch): root = tmp_path / "extensions" root.mkdir() monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root)) monkeypatch.setenv( "HERMES_WEBUI_EXTENSION_SCRIPT_URLS", ", ".join( [ "/extensions/app.js", "https://example.com/evil.js", "//example.com/evil.js", "javascript:alert(1)", "/api/session", "/extensions/../api/session", "/extensions/%2e%2e/api/session", "/extensions/%252e%252e/api/session", "/static/../api/session", ] ), ) monkeypatch.setenv( "HERMES_WEBUI_EXTENSION_STYLESHEET_URLS", "/extensions/app.css, /static/theme.css, data:text/css,body{}", ) from api.extensions import get_extension_config assert get_extension_config() == { "enabled": True, "script_urls": ["/extensions/app.js"], "stylesheet_urls": ["/extensions/app.css", "/static/theme.css"], } def test_index_html_injection_escapes_urls_and_preserves_disabled_default(tmp_path, monkeypatch): monkeypatch.delenv("HERMES_WEBUI_EXTENSION_DIR", raising=False) monkeypatch.setenv("HERMES_WEBUI_EXTENSION_SCRIPT_URLS", "/extensions/app.js") from api.extensions import inject_extension_tags html = "
" assert inject_extension_tags(html) == html root = tmp_path / "extensions" root.mkdir() monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root)) monkeypatch.setenv("HERMES_WEBUI_EXTENSION_SCRIPT_URLS", "/extensions/app.js?v=1&mode=dev") monkeypatch.setenv("HERMES_WEBUI_EXTENSION_STYLESHEET_URLS", "/extensions/app.css") injected = inject_extension_tags(html) assert '' in injected assert '' in injected assert injected.index("/extensions/app.css") < injected.index("") assert injected.index("/extensions/app.js") < injected.index("") def test_extension_route_remains_behind_webui_auth(monkeypatch): monkeypatch.setenv("HERMES_WEBUI_PASSWORD", "test-password") from api.auth import check_auth extension = FakeHandler() # SimpleNamespace must include `query` because api.auth.check_auth (since # v0.50.258, the multi-param ?next= encoding fix) accesses `parsed.query` # when constructing the redirect Location header. assert check_auth(extension, SimpleNamespace(path="/extensions/app.js", query="")) is False assert extension.status == 302 assert extension.header("Location") == "login?next=/extensions/app.js" # Existing core static assets remain public; extension assets intentionally # do not share that exemption because they are administrator-supplied code. static = FakeHandler() assert check_auth(static, SimpleNamespace(path="/static/ui.js", query="")) is True def test_extension_manifest_adds_bundled_assets_before_env_urls(tmp_path, monkeypatch): root = tmp_path / "extensions" root.mkdir() (root / "manifest.json").write_text( """ { "scripts": ["runtime.js"], "stylesheets": ["base.css"], "extensions": [ { "id": "templates", "scripts": ["templates/app.js", "/extensions/shared.js"], "stylesheets": ["templates/app.css"] }, { "id": "disabled", "enabled": false, "scripts": ["disabled.js"], "stylesheets": ["disabled.css"] } ] } """, encoding="utf-8", ) monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root)) monkeypatch.setenv("HERMES_WEBUI_EXTENSION_MANIFEST", "manifest.json") monkeypatch.setenv( "HERMES_WEBUI_EXTENSION_SCRIPT_URLS", "/extensions/templates/app.js, /extensions/env-only.js", ) monkeypatch.setenv( "HERMES_WEBUI_EXTENSION_STYLESHEET_URLS", "/extensions/env-only.css", ) from api.extensions import get_extension_config assert get_extension_config() == { "enabled": True, "script_urls": [ "/extensions/runtime.js", "/extensions/templates/app.js", "/extensions/shared.js", "/extensions/env-only.js", ], "stylesheet_urls": [ "/extensions/base.css", "/extensions/templates/app.css", "/extensions/env-only.css", ], } def test_extension_manifest_reuses_url_safety_rules(tmp_path, monkeypatch): monkeypatch.delenv("HERMES_WEBUI_EXTENSION_SCRIPT_URLS", raising=False) monkeypatch.delenv("HERMES_WEBUI_EXTENSION_STYLESHEET_URLS", raising=False) root = tmp_path / "extensions" root.mkdir() (root / "bundle.json").write_text( """ { "scripts": [ "safe.js", "../escape.js", "/api/session", "https://example.com/evil.js", "/extensions/%252e%252e/api/session" ], "stylesheets": ["safe.css", "nested/../escape.css"] } """, encoding="utf-8", ) monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root)) monkeypatch.setenv("HERMES_WEBUI_EXTENSION_MANIFEST", "bundle.json") from api.extensions import get_extension_config assert get_extension_config() == { "enabled": True, "script_urls": ["/extensions/safe.js"], "stylesheet_urls": ["/extensions/safe.css"], } def test_extension_manifest_deeply_nested_json_fails_safe(tmp_path, monkeypatch): """A <=64KB but deeply-nested manifest makes json.loads raise RecursionError. It must fail safe (empty lists) — NOT escape and 503 every page load.""" monkeypatch.delenv("HERMES_WEBUI_EXTENSION_SCRIPT_URLS", raising=False) monkeypatch.delenv("HERMES_WEBUI_EXTENSION_STYLESHEET_URLS", raising=False) root = tmp_path / "extensions" root.mkdir() # ~6000 nested arrays: well under 64KB but blows the recursion limit in json.loads. depth = 6000 payload = "[" * depth + "]" * depth assert len(payload.encode("utf-8")) < 64 * 1024 (root / "deep.json").write_text(payload, encoding="utf-8") monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root)) monkeypatch.setenv("HERMES_WEBUI_EXTENSION_MANIFEST", "deep.json") from api.extensions import get_extension_config # Must not raise; must fall back to no manifest assets. cfg = get_extension_config() assert cfg["script_urls"] == [] assert cfg["stylesheet_urls"] == [] def test_extension_manifest_path_must_stay_inside_extension_root(tmp_path, monkeypatch): monkeypatch.delenv("HERMES_WEBUI_EXTENSION_STYLESHEET_URLS", raising=False) root = tmp_path / "extensions" root.mkdir() outside = tmp_path / "outside.json" outside.write_text('{"scripts":["outside.js"]}', encoding="utf-8") monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root)) monkeypatch.setenv("HERMES_WEBUI_EXTENSION_MANIFEST", "../outside.json") monkeypatch.setenv("HERMES_WEBUI_EXTENSION_SCRIPT_URLS", "/extensions/env.js") from api.extensions import get_extension_config assert get_extension_config() == { "enabled": True, "script_urls": ["/extensions/env.js"], "stylesheet_urls": [], } def test_extension_manifest_url_list_shares_cap_with_env_urls(tmp_path, monkeypatch): monkeypatch.delenv("HERMES_WEBUI_EXTENSION_STYLESHEET_URLS", raising=False) root = tmp_path / "extensions" root.mkdir() scripts = ",".join(f'"script{i}.js"' for i in range(40)) (root / "manifest.json").write_text(f'{{"scripts":[{scripts}]}}', encoding="utf-8") monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root)) monkeypatch.setenv("HERMES_WEBUI_EXTENSION_MANIFEST", "manifest.json") monkeypatch.setenv("HERMES_WEBUI_EXTENSION_SCRIPT_URLS", "/extensions/env.js") from api.extensions import _MAX_URL_LIST, get_extension_config config = get_extension_config() assert len(config["script_urls"]) == _MAX_URL_LIST assert config["script_urls"][0] == "/extensions/script0.js" assert config["script_urls"][-1] == f"/extensions/script{_MAX_URL_LIST - 1}.js" assert "/extensions/env.js" not in config["script_urls"] def test_extension_manifest_logs_invalid_json_separately_from_oversize(tmp_path, monkeypatch, caplog): root = tmp_path / "extensions" root.mkdir() monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root)) monkeypatch.setenv("HERMES_WEBUI_EXTENSION_MANIFEST", "manifest.json") (root / "manifest.json").write_text('{"scripts": [', encoding="utf-8") from api.extensions import get_extension_config caplog.set_level(logging.WARNING, logger="api.extensions") assert get_extension_config() == { "enabled": True, "script_urls": [], "stylesheet_urls": [], } assert any("not valid JSON" in record.message for record in caplog.records) assert not any("could not be read" in record.message for record in caplog.records) assert not any("exceeds" in record.message for record in caplog.records) def test_extension_manifest_logs_invalid_utf8_as_unreadable(tmp_path, monkeypatch, caplog): root = tmp_path / "extensions" root.mkdir() monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root)) monkeypatch.setenv("HERMES_WEBUI_EXTENSION_MANIFEST", "manifest.json") (root / "manifest.json").write_bytes(b"{\xff}") from api.extensions import get_extension_config caplog.set_level(logging.WARNING, logger="api.extensions") assert get_extension_config() == { "enabled": True, "script_urls": [], "stylesheet_urls": [], } assert any("could not be read" in record.message for record in caplog.records) assert not any("exceeds" in record.message for record in caplog.records) def test_extension_manifest_logs_oversize_distinctly(tmp_path, monkeypatch, caplog): root = tmp_path / "extensions" root.mkdir() monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root)) monkeypatch.setenv("HERMES_WEBUI_EXTENSION_MANIFEST", "manifest.json") from api import extensions (root / "manifest.json").write_text( '{"scripts":["pwn.js"]}' + (" " * extensions._MAX_MANIFEST_BYTES), encoding="utf-8", ) caplog.set_level(logging.WARNING, logger="api.extensions") assert extensions.get_extension_config() == { "enabled": True, "script_urls": [], "stylesheet_urls": [], } assert any("exceeds" in record.message for record in caplog.records) assert not any("not valid JSON" in record.message for record in caplog.records) def test_extension_manifest_reads_only_bounded_size(tmp_path, monkeypatch): root = tmp_path / "extensions" root.mkdir() monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root)) monkeypatch.setenv("HERMES_WEBUI_EXTENSION_MANIFEST", "manifest.json") from api import extensions # If the bounded read/cap check is removed, this parses as valid JSON and # would inject /extensions/pwn.js. The trailing padding makes it oversize. oversize = '{"scripts":["pwn.js"]}' + (" " * extensions._MAX_MANIFEST_BYTES) (root / "manifest.json").write_text(oversize, encoding="utf-8") assert extensions.get_extension_config() == { "enabled": True, "script_urls": [], "stylesheet_urls": [], } def test_extension_manifest_multibyte_payload_is_bounded_by_bytes(tmp_path, monkeypatch): root = tmp_path / "extensions" root.mkdir() monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root)) monkeypatch.setenv("HERMES_WEBUI_EXTENSION_MANIFEST", "manifest.json") from api import extensions oversize_multibyte = '{"scripts":["pwn.js"]}' + ("€" * extensions._MAX_MANIFEST_BYTES) (root / "manifest.json").write_text(oversize_multibyte, encoding="utf-8") assert extensions.get_extension_config() == { "enabled": True, "script_urls": [], "stylesheet_urls": [], } def test_extension_manifest_cap_warning_logs_once_across_many_entries(tmp_path, monkeypatch, caplog): root = tmp_path / "extensions" root.mkdir() entries = [ { "id": f"ext{i}", "scripts": [f"script{i}.js"], "stylesheets": [f"style{i}.css"], } for i in range(40) ] (root / "manifest.json").write_text( __import__("json").dumps({"extensions": entries}), encoding="utf-8" ) monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root)) monkeypatch.setenv("HERMES_WEBUI_EXTENSION_MANIFEST", "manifest.json") from api.extensions import _MAX_URL_LIST, _warned_urls, get_extension_config _warned_urls.clear() caplog.set_level(logging.WARNING, logger="api.extensions") config = get_extension_config() assert len(config["script_urls"]) == _MAX_URL_LIST assert len(config["stylesheet_urls"]) == _MAX_URL_LIST truncation_records = [ record for record in caplog.records if "truncated at" in record.message and "Extension URL list" in record.message ] assert len(truncation_records) == 2 assert any("manifest:scripts" in record.message for record in truncation_records) assert any("manifest:stylesheets" in record.message for record in truncation_records) def test_extension_manifest_ignores_non_list_asset_fields(tmp_path, monkeypatch): root = tmp_path / "extensions" root.mkdir() (root / "manifest.json").write_text( """ { "scripts": "app.js", "stylesheets": {"href": "app.css"}, "extensions": { "bad": {"scripts": ["bad.js"]} } } """, encoding="utf-8", ) monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root)) monkeypatch.setenv("HERMES_WEBUI_EXTENSION_MANIFEST", "manifest.json") monkeypatch.setenv("HERMES_WEBUI_EXTENSION_SCRIPT_URLS", "/extensions/env.js") monkeypatch.delenv("HERMES_WEBUI_EXTENSION_STYLESHEET_URLS", raising=False) from api.extensions import get_extension_config assert get_extension_config() == { "enabled": True, "script_urls": ["/extensions/env.js"], "stylesheet_urls": [], } def test_extension_env_only_duplicate_urls_preserve_legacy_behavior(tmp_path, monkeypatch): root = tmp_path / "extensions" root.mkdir() monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root)) monkeypatch.delenv("HERMES_WEBUI_EXTENSION_MANIFEST", raising=False) monkeypatch.setenv( "HERMES_WEBUI_EXTENSION_SCRIPT_URLS", "/extensions/app.js, /extensions/app.js", ) monkeypatch.delenv("HERMES_WEBUI_EXTENSION_STYLESHEET_URLS", raising=False) from api.extensions import get_extension_config assert get_extension_config() == { "enabled": True, "script_urls": ["/extensions/app.js", "/extensions/app.js"], "stylesheet_urls": [], } def test_extension_manifest_accepts_top_level_extension_array(tmp_path, monkeypatch): root = tmp_path / "extensions" root.mkdir() (root / "extensions.json").write_text( """ [ {"id": "a", "scripts": ["a/a.js"], "stylesheets": ["a/a.css"]}, {"id": "b", "scripts": ["b/b.js"]} ] """, encoding="utf-8", ) monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root)) monkeypatch.setenv("HERMES_WEBUI_EXTENSION_MANIFEST", "extensions.json") monkeypatch.delenv("HERMES_WEBUI_EXTENSION_SCRIPT_URLS", raising=False) monkeypatch.delenv("HERMES_WEBUI_EXTENSION_STYLESHEET_URLS", raising=False) from api.extensions import get_extension_config assert get_extension_config() == { "enabled": True, "script_urls": ["/extensions/a/a.js", "/extensions/b/b.js"], "stylesheet_urls": ["/extensions/a/a.css"], } def test_extension_static_serving_is_sandboxed(tmp_path, monkeypatch): root = tmp_path / "extensions" root.mkdir() (root / "app.js").write_text("window.extensionLoaded = true;", encoding="utf-8") (root / ".secret").write_text("do not serve", encoding="utf-8") outside = tmp_path / "outside.txt" outside.write_text("outside", encoding="utf-8") monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root)) from api.extensions import serve_extension_static ok = FakeHandler() assert serve_extension_static(ok, SimpleNamespace(path="/extensions/app.js")) is True assert ok.status == 200 assert ok.header("Content-Type") == "application/javascript; charset=utf-8" assert bytes(ok.body) == b"window.extensionLoaded = true;" traversal = FakeHandler() assert serve_extension_static(traversal, SimpleNamespace(path="/extensions/../outside.txt")) is True assert traversal.status == 404 encoded_traversal = FakeHandler() assert serve_extension_static(encoded_traversal, SimpleNamespace(path="/extensions/%2e%2e/outside.txt")) is True assert encoded_traversal.status == 404 dotfile = FakeHandler() assert serve_extension_static(dotfile, SimpleNamespace(path="/extensions/.secret")) is True assert dotfile.status == 404 def test_extension_static_serving_fails_closed_when_disabled_or_unreadable(tmp_path, monkeypatch): missing_root = tmp_path / "missing" monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(missing_root)) from api.extensions import serve_extension_static disabled = FakeHandler() assert serve_extension_static(disabled, SimpleNamespace(path="/extensions/app.js")) is True assert disabled.status == 404 root = tmp_path / "extensions" root.mkdir() monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root)) (root / "nested").mkdir() (root / "nested" / "app.js").write_text("ok", encoding="utf-8") encoded_slash_traversal = FakeHandler() assert serve_extension_static( encoded_slash_traversal, SimpleNamespace(path="/extensions/nested%2f..%2f..%2foutside.txt"), ) is True assert encoded_slash_traversal.status == 404 encoded_backslash = FakeHandler() assert serve_extension_static(encoded_backslash, SimpleNamespace(path="/extensions/nested%5capp.js")) is True assert encoded_backslash.status == 404 def test_extension_static_serving_rejects_symlink_escape(tmp_path, monkeypatch): root = tmp_path / "extensions" root.mkdir() outside = tmp_path / "outside.txt" outside.write_text("outside", encoding="utf-8") symlink = root / "outside-link.txt" try: symlink.symlink_to(outside) except OSError: # Some platforms/filesystems disallow symlink creation. The path-safety # behavior is still covered by traversal tests above. return monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root)) from api.extensions import serve_extension_static escaped = FakeHandler() assert serve_extension_static(escaped, SimpleNamespace(path="/extensions/outside-link.txt")) is True assert escaped.status == 404