diff --git a/README.md b/README.md index bdf57283..d75cd8f2 100644 --- a/README.md +++ b/README.md @@ -254,6 +254,9 @@ Full list of environment variables: | `HERMES_WEBUI_DEFAULT_WORKSPACE` | `~/workspace` | Default workspace | | `HERMES_WEBUI_DEFAULT_MODEL` | `openai/gpt-5.4-mini` | Default model | | `HERMES_WEBUI_PASSWORD` | *(unset)* | Set to enable password authentication | +| `HERMES_WEBUI_EXTENSION_DIR` | *(unset)* | Optional local directory served at `/extensions/`; must point to an existing directory before extension injection is enabled | +| `HERMES_WEBUI_EXTENSION_SCRIPT_URLS` | *(unset)* | Optional comma-separated same-origin script URLs to inject; see [WebUI Extensions](docs/EXTENSIONS.md) | +| `HERMES_WEBUI_EXTENSION_STYLESHEET_URLS` | *(unset)* | Optional comma-separated same-origin stylesheet URLs to inject; see [WebUI Extensions](docs/EXTENSIONS.md) | | `HERMES_HOME` | `~/.hermes` | Base directory for Hermes state (affects all paths) | | `HERMES_CONFIG_PATH` | `~/.hermes/config.yaml` | Path to Hermes config file | diff --git a/api/extensions.py b/api/extensions.py new file mode 100644 index 00000000..7466aed5 --- /dev/null +++ b/api/extensions.py @@ -0,0 +1,202 @@ +"""Opt-in WebUI extension hooks. + +This module intentionally provides a small, self-hosted extension surface: +configured same-origin script/style injection plus sandboxed static file serving. +It is disabled by default and never executes or fetches third-party URLs. +""" + +import html +import os +from pathlib import Path +from typing import Dict, List, Optional +from urllib.parse import unquote, urlsplit + +from api.helpers import _security_headers, j + +EXTENSION_ROUTE_PREFIX = "/extensions/" +_EXTENSION_DIR_ENV = "HERMES_WEBUI_EXTENSION_DIR" +_EXTENSION_SCRIPT_URLS_ENV = "HERMES_WEBUI_EXTENSION_SCRIPT_URLS" +_EXTENSION_STYLESHEET_URLS_ENV = "HERMES_WEBUI_EXTENSION_STYLESHEET_URLS" +_ALLOWED_ASSET_PREFIXES = ("/extensions/", "/static/") + +_EXTENSION_MIME = { + "css": "text/css", + "js": "application/javascript", + "html": "text/html", + "svg": "image/svg+xml", + "png": "image/png", + "jpg": "image/jpeg", + "jpeg": "image/jpeg", + "ico": "image/x-icon", + "gif": "image/gif", + "webp": "image/webp", + "woff": "font/woff", + "woff2": "font/woff2", +} +_TEXT_MIME_TYPES = {"text/css", "application/javascript", "text/html", "image/svg+xml", "text/plain"} + + +def _extension_root() -> Optional[Path]: + """Return the configured extension directory, or None when disabled. + + A missing or non-directory path disables extensions instead of failing open. + The startup docs encourage users to point this at a directory they control. + """ + raw = os.getenv(_EXTENSION_DIR_ENV, "").strip() + if not raw: + return None + root = Path(raw).expanduser().resolve() + if not root.exists() or not root.is_dir(): + return None + return root + + +def _fully_unquote_path(path: str) -> str: + """Decode percent-encoding until stable so encoded dot-segments cannot hide.""" + previous = path + for _ in range(3): + current = unquote(previous) + if current == previous: + return current + previous = current + return previous + + +def _is_safe_asset_url(value: str) -> bool: + """Allow only same-origin extension/static asset URLs. + + External schemes, protocol-relative URLs, fragments, arbitrary API paths, and + encoded traversal are rejected so enabling extensions does not require + loosening the CSP. + """ + if not value or any(ch in value for ch in ('\x00', '\r', '\n', '"', "'", "<", ">", "\\")): + return False + parsed = urlsplit(value) + if parsed.scheme or parsed.netloc or parsed.fragment: + return False + + decoded_path = _fully_unquote_path(parsed.path) + if not any(decoded_path.startswith(prefix) for prefix in _ALLOWED_ASSET_PREFIXES): + return False + + for prefix in _ALLOWED_ASSET_PREFIXES: + if decoded_path.startswith(prefix): + return _is_safe_relative_path(decoded_path[len(prefix) :]) + return False + + +def _read_url_list(env_name: str) -> List[str]: + raw = os.getenv(env_name, "") + urls = [] + for item in raw.split(","): + value = item.strip() + if value and _is_safe_asset_url(value): + urls.append(value) + return urls + + +def get_extension_config() -> Dict[str, object]: + """Return public extension config without exposing filesystem paths.""" + enabled = _extension_root() is not None + if not enabled: + return {"enabled": False, "script_urls": [], "stylesheet_urls": []} + return { + "enabled": True, + "script_urls": _read_url_list(_EXTENSION_SCRIPT_URLS_ENV), + "stylesheet_urls": _read_url_list(_EXTENSION_STYLESHEET_URLS_ENV), + } + + +def inject_extension_tags(index_html: str) -> str: + """Inject configured extension tags into the app shell. + + Tags are inserted only when the extension directory is enabled. URLs are + escaped even though they are already validated, keeping the renderer robust + if validation rules evolve later. + """ + config = get_extension_config() + if not config["enabled"]: + return index_html + + result = index_html + stylesheet_tags = [ + ''.format(html.escape(url, quote=True)) + for url in config["stylesheet_urls"] + ] + script_tags = [ + ''.format(html.escape(url, quote=True)) + for url in config["script_urls"] + ] + + if stylesheet_tags: + head_marker = "" + block = "\n".join(stylesheet_tags) + "\n" + if head_marker in result: + result = result.replace(head_marker, block + head_marker, 1) + else: + result = block + result + + if script_tags: + body_marker = "" + block = "\n".join(script_tags) + "\n" + if body_marker in result: + result = result.replace(body_marker, block + body_marker, 1) + else: + result = result + "\n" + block + + return result + + +def _is_safe_relative_path(rel: str) -> bool: + if not rel or "\x00" in rel or "\\" in rel: + return False + for segment in rel.split("/"): + if not segment or segment in (".", "..") or segment.startswith("."): + return False + return True + + +def _not_found(handler) -> bool: + j(handler, {"error": "not found"}, status=404) + return True + + +def serve_extension_static(handler, parsed) -> bool: + """Serve a file from the configured extension directory. + + The function always returns True for /extensions/* requests: either a file + response or a 404. It never reveals why a request failed, which avoids + leaking local paths or extension configuration details. + """ + root = _extension_root() + if root is None: + return _not_found(handler) + + rel = unquote(parsed.path[len(EXTENSION_ROUTE_PREFIX) :]) + if not _is_safe_relative_path(rel): + return _not_found(handler) + + static_file = (root / rel).resolve() + try: + static_file.relative_to(root) + except ValueError: + return _not_found(handler) + + if not static_file.exists() or not static_file.is_file(): + return _not_found(handler) + + ct = _EXTENSION_MIME.get(static_file.suffix.lower().lstrip("."), "text/plain") + ct_header = "{}; charset=utf-8".format(ct) if ct in _TEXT_MIME_TYPES else ct + try: + raw = static_file.read_bytes() + except OSError: + return _not_found(handler) + + handler.send_response(200) + handler.send_header("Content-Type", ct_header) + handler.send_header("Cache-Control", "no-store") + handler.send_header("Content-Length", str(len(raw))) + _security_headers(handler) + handler.end_headers() + handler.wfile.write(raw) + return True diff --git a/api/routes.py b/api/routes.py index 4106d07c..d3cd0d68 100644 --- a/api/routes.py +++ b/api/routes.py @@ -1084,9 +1084,12 @@ def handle_get(handler, parsed) -> bool: from urllib.parse import quote from api.updates import WEBUI_VERSION version_token = quote(WEBUI_VERSION, safe="") + from api.extensions import inject_extension_tags + + html = _INDEX_HTML_PATH.read_text(encoding="utf-8").replace("__WEBUI_VERSION__", version_token) return t( handler, - _INDEX_HTML_PATH.read_text(encoding="utf-8").replace("__WEBUI_VERSION__", version_token), + inject_extension_tags(html), content_type="text/html; charset=utf-8", ) @@ -1225,6 +1228,11 @@ def handle_get(handler, parsed) -> bool: if parsed.path == "/api/onboarding/status": return j(handler, get_onboarding_status()) + if parsed.path.startswith("/extensions/"): + from api.extensions import serve_extension_static + + return serve_extension_static(handler, parsed) + if parsed.path.startswith("/static/"): return _serve_static(handler, parsed) diff --git a/docs/EXTENSIONS.md b/docs/EXTENSIONS.md new file mode 100644 index 00000000..a3cc410d --- /dev/null +++ b/docs/EXTENSIONS.md @@ -0,0 +1,204 @@ +# WebUI Extensions + +Hermes WebUI supports a small, opt-in extension surface for self-hosted installs. +It lets an administrator serve local static assets and inject same-origin CSS or +JavaScript into the app shell without editing the WebUI source tree. + +This is intentionally not a plugin marketplace or dependency system. It is a +safe escape hatch for local dashboards, internal tooling, and workflow-specific +panels that should not live in core Hermes WebUI. + +## What extensions can do + +Extensions can: + +- serve files from one configured local directory at `/extensions/...` +- inject configured same-origin stylesheets into `` +- inject configured same-origin scripts before `` +- call the normal WebUI APIs available to the browser session + +Extensions cannot, by themselves: + +- bypass WebUI authentication +- serve files outside the configured extension directory +- load third-party scripts/styles through the built-in injection config +- change Hermes Agent permissions, models, memory, or tools unless they call + existing authenticated APIs that already allow those changes + +## Configuration + +Extensions are disabled by default. Configure them with environment variables +before starting the WebUI server. `HERMES_WEBUI_EXTENSION_DIR` must point to an +existing directory before any script or stylesheet URLs are injected: + +```bash +export HERMES_WEBUI_EXTENSION_DIR=/path/to/my-extension/static +export HERMES_WEBUI_EXTENSION_SCRIPT_URLS=/extensions/app.js +export HERMES_WEBUI_EXTENSION_STYLESHEET_URLS=/extensions/app.css +./start.sh +``` + +Multiple URLs may be comma-separated: + +```bash +export HERMES_WEBUI_EXTENSION_SCRIPT_URLS=/extensions/runtime.js,/extensions/app.js +export HERMES_WEBUI_EXTENSION_STYLESHEET_URLS=/extensions/base.css,/extensions/theme.css +``` + +## URL rules + +Injected asset URLs are deliberately restricted: + +- must be same-origin paths +- must start with `/extensions/` or `/static/` +- must not include a URL scheme, host, fragment, quote, angle bracket, newline, + NUL byte, or backslash + +Allowed examples: + +```text +/extensions/app.js +/extensions/app.css +/extensions/app.js?v=1 +/static/theme.css +``` + +Rejected examples: + +```text +https://example.com/app.js +//example.com/app.js +javascript:alert(1) +/api/session +/extensions/app.js#fragment +``` + +These restrictions keep the existing Content Security Policy intact and avoid +turning the extension hook into a third-party script loader. Invalid configured +URLs are ignored rather than injected. + +## Static file serving + +When `HERMES_WEBUI_EXTENSION_DIR` points at an existing directory, files under +that directory are available below `/extensions/`: + +```text +/path/to/my-extension/static/app.js -> /extensions/app.js +/path/to/my-extension/static/ui.css -> /extensions/ui.css +``` + +The static handler is sandboxed: + +- path traversal is rejected, including encoded traversal +- dotfiles and dot-directories are not served +- symlinks that resolve outside the extension directory are rejected +- missing or invalid extension directories behave as disabled +- failures return a generic 404 without exposing local filesystem paths + +## Security notes + +Only enable extensions from directories you control. Extension JavaScript runs in +the WebUI origin and can call the same authenticated WebUI APIs as the logged-in +browser session. + +For shared or remotely exposed installations: + +- keep `HERMES_WEBUI_PASSWORD` enabled +- bind to loopback unless you intentionally expose the service +- review extension code before enabling it +- prefer small, auditable extension files +- avoid serving generated or user-writable directories as extension roots + +## Extension authoring guidance + +Extensions share the page with the WebUI app, so they should be additive and +reversible. Prefer small, well-scoped DOM changes that can be removed or hidden +without breaking the built-in Chat, Tasks, Settings, or session views. + +Recommended patterns: + +- create extension-specific containers with unique IDs or class prefixes +- add UI next to existing views instead of replacing large app containers +- keep event listeners scoped to extension-owned elements where possible +- preserve built-in navigation behavior and restore any view state you change +- use `hidden`, `aria-*`, and extension-scoped CSS for panels or overlays +- guard initialization so reloading or re-injecting the script does not create + duplicate buttons, panels, timers, or event listeners + +Avoid destructive mutations such as replacing `document.body.innerHTML`, +`main.innerHTML`, or other broad WebUI containers. Those patterns can remove or +mask the app's existing panels and leave normal navigation unable to recover +after an extension view is opened. + +For custom pages, prefer adding a dedicated panel and toggling it alongside the +built-in views: + +```javascript +(() => { + if (document.getElementById('my-extension-panel')) return; + + const panel = document.createElement('section'); + panel.id = 'my-extension-panel'; + panel.className = 'main-view my-extension-panel'; + panel.hidden = true; + panel.textContent = 'My extension page'; + + document.querySelector('main')?.appendChild(panel); + + function showPanel() { + document.querySelectorAll('main > .main-view').forEach((view) => { + view.hidden = view !== panel; + }); + } + + // Wire showPanel() to an extension-owned button or menu item. +})(); +``` + +If host CSS overrides `[hidden]`, add an extension-scoped rule such as: + +```css +.my-extension-panel[hidden] { + display: none !important; +} +``` + +## Minimal example + +Create a local extension directory: + +```bash +mkdir -p ~/.hermes/webui-extension +cat > ~/.hermes/webui-extension/app.css <<'CSS' +.my-extension-badge { + position: fixed; + right: 12px; + bottom: 12px; + padding: 6px 10px; + border-radius: 999px; + background: #202236; + color: #fff; + font: 12px system-ui, sans-serif; + z-index: 9999; +} +CSS +cat > ~/.hermes/webui-extension/app.js <<'JS' +(() => { + const badge = document.createElement('div'); + badge.className = 'my-extension-badge'; + badge.textContent = 'Extension loaded'; + document.body.appendChild(badge); +})(); +JS +``` + +Start WebUI with the extension enabled: + +```bash +HERMES_WEBUI_EXTENSION_DIR=~/.hermes/webui-extension \ +HERMES_WEBUI_EXTENSION_STYLESHEET_URLS=/extensions/app.css \ +HERMES_WEBUI_EXTENSION_SCRIPT_URLS=/extensions/app.js \ +./start.sh +``` + +Open the WebUI and confirm the badge appears. diff --git a/tests/test_extension_hooks.py b/tests/test_extension_hooks.py new file mode 100644 index 00000000..ac1d7103 --- /dev/null +++ b/tests/test_extension_hooks.py @@ -0,0 +1,206 @@ +"""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() + assert check_auth(extension, SimpleNamespace(path="/extensions/app.js")) is False + assert extension.status == 302 + assert extension.header("Location") == "/login" + + # 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")) 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