"""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 """ from types import SimpleNamespace 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_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