stage #4561 + stamp v0.51.545 — extension manifest asset bundles
santastabber. HERMES_WEBUI_EXTENSION_MANIFEST: bundle extension assets via a 64KB JSON manifest instead of long URL lists. Same same-origin allowlist validator, traversal-guarded manifest path, default-off. Nathan blessed (richer extension system). Co-authored-by: santastabber <santastabber@users.noreply.github.com>
This commit is contained in:
1 parent
36611b7e20
commit
3e229141af
5 files changed
+584
-35
No files matched your search
@@ -3,6 +3,12 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [v0.51.545] — 2026-06-20 — Release TD (extension manifest asset bundles)
|
||||
|
||||
### Added
|
||||
|
||||
- **Bundle extension assets with a manifest instead of long comma-separated URL lists (#4505).** A new `HERMES_WEBUI_EXTENSION_MANIFEST` env points to a small JSON manifest (capped at 64 KB) that lists an extension's `scripts`/`stylesheets`, so self-hosters running multiple local extensions no longer have to maintain unwieldy `HERMES_WEBUI_EXTENSION_SCRIPT_URLS` strings. Per-extension `enabled: false` skips a bundle. The manifest path is traversal-guarded (must resolve inside the extension dir; absolute/`~` paths rejected), and every asset URL still goes through the exact same same-origin allowlist validator as before (`/extensions/` or `/static/` only — never third-party fetch), capped at 32 entries and deduped. Fully opt-in and default-off; with no manifest env set, behavior is unchanged. Thanks @santastabber.
|
||||
|
||||
## [v0.51.544] — 2026-06-20 — Release TC (opt-in: render markdown in your own messages)
|
||||
|
||||
### Added
|
||||
|
||||
@@ -344,8 +344,9 @@ Full list of environment variables:
|
||||
| `HERMES_WEBUI_CSP_CONNECT_EXTRA` | *(unset)* | Optional space-separated `http(s)://` or `ws(s)://` origins to append to the report-only CSP `connect-src` directive for reverse-proxy or tunnel deployments |
|
||||
| `HERMES_WEBUI_SSE_CHUNKED` | *(unset)* | Set truthy (`1`/`true`/`yes`/`on`) to send SSE with `Transfer-Encoding: chunked`. Needed behind buffering reverse proxies (e.g. `jupyter-server-proxy`) that otherwise buffer the whole stream; harmless but unnecessary for directly-served deployments |
|
||||
| `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_WEBUI_EXTENSION_MANIFEST` | *(unset)* | Optional relative JSON manifest inside `HERMES_WEBUI_EXTENSION_DIR` listing bundled scripts/styles to inject; see [WebUI Extensions](docs/EXTENSIONS.md) |
|
||||
| `HERMES_WEBUI_EXTENSION_SCRIPT_URLS` | *(unset)* | Optional comma-separated same-origin script URLs to inject; appended after manifest scripts; see [WebUI Extensions](docs/EXTENSIONS.md) |
|
||||
| `HERMES_WEBUI_EXTENSION_STYLESHEET_URLS` | *(unset)* | Optional comma-separated same-origin stylesheet URLs to inject; appended after manifest stylesheets; see [WebUI Extensions](docs/EXTENSIONS.md) |
|
||||
| `HERMES_HOME` | Windows: `%LOCALAPPDATA%\hermes`; POSIX: `~/.hermes` | Base directory for Hermes state (affects all paths) |
|
||||
| `HERMES_CONFIG_PATH` | `$HERMES_HOME/config.yaml` | Path to Hermes config file |
|
||||
| `HERMES_WEBUI_SERVER_CWD` | *(unset)* | Working directory for the server process. Defaults to the agent dir; point it at a writable workspace when the agent dir is read-only so fallback relative writes land somewhere writable |
|
||||
|
||||
+181
-32
@@ -6,10 +6,11 @@ It is disabled by default and never executes or fetches third-party URLs.
|
||||
"""
|
||||
|
||||
import html
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from urllib.parse import unquote, urlsplit
|
||||
|
||||
from api.helpers import _security_headers, j
|
||||
@@ -22,14 +23,24 @@ _log = logging.getLogger(__name__)
|
||||
# avoids rendering tens of thousands of <script> tags into every page load.
|
||||
_MAX_URL_LIST = 32
|
||||
|
||||
# Keep extension manifests small and auditable. The manifest is a convenience for
|
||||
# bundling static assets, not a package manager or dependency lockfile.
|
||||
_MAX_MANIFEST_BYTES = 64 * 1024
|
||||
|
||||
# Tracks rejected URL strings we've already warned about so a misconfigured env
|
||||
# var doesn't spam the log on every request that re-reads it.
|
||||
_warned_urls: set = set()
|
||||
|
||||
|
||||
class _ManifestTooLarge(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
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"
|
||||
_EXTENSION_MANIFEST_ENV = "HERMES_WEBUI_EXTENSION_MANIFEST"
|
||||
_ALLOWED_ASSET_PREFIXES = ("/extensions/", "/static/")
|
||||
|
||||
_EXTENSION_MIME = {
|
||||
@@ -107,47 +118,185 @@ def _is_safe_asset_url(value: str) -> bool:
|
||||
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 not value:
|
||||
continue
|
||||
if _is_safe_asset_url(value):
|
||||
urls.append(value)
|
||||
if len(urls) >= _MAX_URL_LIST:
|
||||
# Stop accumulating after the cap. Anything past this point
|
||||
# would be silently dropped anyway; logging once makes the
|
||||
# truncation visible to a confused operator.
|
||||
if env_name not in _warned_urls:
|
||||
_warned_urls.add(env_name)
|
||||
_log.warning(
|
||||
"Extension URL list %s truncated at %d entries",
|
||||
env_name, _MAX_URL_LIST,
|
||||
)
|
||||
break
|
||||
elif value not in _warned_urls:
|
||||
# First-time-seen invalid URL: log once per process so a typo
|
||||
# in HERMES_WEBUI_EXTENSION_*_URLS doesn't disappear silently.
|
||||
_warned_urls.add(value)
|
||||
def _warn_rejected_url(value: str, source: str) -> None:
|
||||
if value in _warned_urls:
|
||||
return
|
||||
_warned_urls.add(value)
|
||||
_log.warning(
|
||||
"Rejected extension URL %r from %s (not a same-origin "
|
||||
"/extensions/ or /static/ path, or contains unsafe chars)",
|
||||
value, source,
|
||||
)
|
||||
|
||||
|
||||
def _append_safe_asset_url(
|
||||
urls: List[str], value: str, source: str, *, dedupe: bool = True
|
||||
) -> bool:
|
||||
"""Append a validated URL while preserving order and the global cap.
|
||||
|
||||
Returns False when the caller should stop accumulating entries for this list.
|
||||
Manifest paths dedupe by default, while env-only lists preserve their legacy
|
||||
behavior unless they are appending after manifest-provided assets.
|
||||
"""
|
||||
value = value.strip() if isinstance(value, str) else ""
|
||||
if not value:
|
||||
return True
|
||||
if not _is_safe_asset_url(value):
|
||||
_warn_rejected_url(value, source)
|
||||
return True
|
||||
if dedupe and value in urls:
|
||||
return True
|
||||
if len(urls) >= _MAX_URL_LIST:
|
||||
if source not in _warned_urls:
|
||||
_warned_urls.add(source)
|
||||
_log.warning(
|
||||
"Rejected extension URL %r from %s (not a same-origin "
|
||||
"/extensions/ or /static/ path, or contains unsafe chars)",
|
||||
value, env_name,
|
||||
"Extension URL list %s truncated at %d entries",
|
||||
source, _MAX_URL_LIST,
|
||||
)
|
||||
return False
|
||||
urls.append(value)
|
||||
return True
|
||||
|
||||
|
||||
def _read_url_list(env_name: str, existing: Optional[List[str]] = None) -> List[str]:
|
||||
raw = os.getenv(env_name, "")
|
||||
urls = list(existing or [])
|
||||
# Preserve legacy env-only behavior: duplicate env URLs injected twice before
|
||||
# manifests existed. When a manifest seeds the list, dedupe appended env URLs
|
||||
# so bundle manifests and explicit overrides do not double-load an asset.
|
||||
dedupe = existing is not None
|
||||
for item in raw.split(","):
|
||||
if not _append_safe_asset_url(urls, item, env_name, dedupe=dedupe):
|
||||
break
|
||||
return urls
|
||||
|
||||
|
||||
def _manifest_path(root: Path) -> Optional[Path]:
|
||||
raw = os.getenv(_EXTENSION_MANIFEST_ENV, "").strip()
|
||||
if not raw:
|
||||
return None
|
||||
if raw.startswith(("/", "~")):
|
||||
_log.warning("Rejected extension manifest path from %s", _EXTENSION_MANIFEST_ENV)
|
||||
return None
|
||||
rel = _fully_unquote_path(raw)
|
||||
if not _is_safe_relative_path(rel):
|
||||
_log.warning("Rejected extension manifest path from %s", _EXTENSION_MANIFEST_ENV)
|
||||
return None
|
||||
manifest = (root / rel).resolve()
|
||||
try:
|
||||
manifest.relative_to(root)
|
||||
except ValueError:
|
||||
_log.warning("Rejected extension manifest path from %s", _EXTENSION_MANIFEST_ENV)
|
||||
return None
|
||||
return manifest
|
||||
|
||||
|
||||
def _manifest_asset_url(value: object) -> str:
|
||||
"""Normalize a manifest asset entry to the existing same-origin URL format."""
|
||||
if not isinstance(value, str):
|
||||
return ""
|
||||
item = value.strip()
|
||||
if not item:
|
||||
return ""
|
||||
parsed = urlsplit(item)
|
||||
if parsed.scheme or parsed.netloc or item.startswith("//"):
|
||||
return item
|
||||
# Manifests are meant to make bundled local assets less noisy to list, so
|
||||
# bare relative paths resolve under /extensions/. Absolute same-origin paths
|
||||
# are still allowed and go through the same validator as env-configured URLs.
|
||||
if item.startswith("/"):
|
||||
return item
|
||||
return EXTENSION_ROUTE_PREFIX + item
|
||||
|
||||
|
||||
def _iter_manifest_entries(manifest: object) -> List[Tuple[str, object]]:
|
||||
entries: List[Tuple[str, object]] = []
|
||||
extension_entries: object = []
|
||||
if isinstance(manifest, dict):
|
||||
entries.append(("manifest", manifest))
|
||||
extension_entries = manifest.get("extensions", [])
|
||||
elif isinstance(manifest, list):
|
||||
extension_entries = manifest
|
||||
if isinstance(extension_entries, list):
|
||||
for index, extension in enumerate(extension_entries):
|
||||
if isinstance(extension, dict) and extension.get("enabled", True) is False:
|
||||
continue
|
||||
entries.append((f"manifest.extensions[{index}]", extension))
|
||||
return entries
|
||||
|
||||
|
||||
def _entry_asset_values(entry: Dict[str, object], key: str) -> List[object]:
|
||||
values = entry.get(key, [])
|
||||
return values if isinstance(values, list) else []
|
||||
|
||||
|
||||
def _read_manifest_text(manifest_file: Path) -> str:
|
||||
with manifest_file.open("rb") as fh:
|
||||
data = fh.read(_MAX_MANIFEST_BYTES + 1)
|
||||
if len(data) > _MAX_MANIFEST_BYTES:
|
||||
raise _ManifestTooLarge("manifest too large")
|
||||
return data.decode("utf-8")
|
||||
|
||||
|
||||
def _read_manifest_urls(root: Path) -> Tuple[List[str], List[str]]:
|
||||
manifest_file = _manifest_path(root)
|
||||
if manifest_file is None:
|
||||
return [], []
|
||||
try:
|
||||
if not manifest_file.exists() or not manifest_file.is_file():
|
||||
_log.warning("Configured extension manifest was not found")
|
||||
return [], []
|
||||
manifest = json.loads(_read_manifest_text(manifest_file))
|
||||
except _ManifestTooLarge:
|
||||
_log.warning("Configured extension manifest exceeds %d bytes", _MAX_MANIFEST_BYTES)
|
||||
return [], []
|
||||
except json.JSONDecodeError:
|
||||
_log.warning("Configured extension manifest is not valid JSON")
|
||||
return [], []
|
||||
except (OSError, UnicodeDecodeError):
|
||||
_log.warning("Configured extension manifest could not be read")
|
||||
return [], []
|
||||
|
||||
scripts: List[str] = []
|
||||
stylesheets: List[str] = []
|
||||
scripts_full = False
|
||||
stylesheets_full = False
|
||||
for _source, entry in _iter_manifest_entries(manifest):
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
if not scripts_full:
|
||||
for value in _entry_asset_values(entry, "scripts"):
|
||||
if not _append_safe_asset_url(
|
||||
scripts, _manifest_asset_url(value), "manifest:scripts"
|
||||
):
|
||||
scripts_full = True
|
||||
break
|
||||
if not stylesheets_full:
|
||||
for value in _entry_asset_values(entry, "stylesheets"):
|
||||
if not _append_safe_asset_url(
|
||||
stylesheets, _manifest_asset_url(value), "manifest:stylesheets"
|
||||
):
|
||||
stylesheets_full = True
|
||||
break
|
||||
if scripts_full and stylesheets_full:
|
||||
break
|
||||
return scripts, stylesheets
|
||||
|
||||
|
||||
def get_extension_config() -> Dict[str, object]:
|
||||
"""Return public extension config without exposing filesystem paths."""
|
||||
enabled = _extension_root() is not None
|
||||
if not enabled:
|
||||
root = _extension_root()
|
||||
if root is None:
|
||||
return {"enabled": False, "script_urls": [], "stylesheet_urls": []}
|
||||
manifest_scripts, manifest_stylesheets = _read_manifest_urls(root)
|
||||
return {
|
||||
"enabled": True,
|
||||
"script_urls": _read_url_list(_EXTENSION_SCRIPT_URLS_ENV),
|
||||
"stylesheet_urls": _read_url_list(_EXTENSION_STYLESHEET_URLS_ENV),
|
||||
"script_urls": _read_url_list(
|
||||
_EXTENSION_SCRIPT_URLS_ENV, manifest_scripts or None
|
||||
),
|
||||
"stylesheet_urls": _read_url_list(
|
||||
_EXTENSION_STYLESHEET_URLS_ENV, manifest_stylesheets or None
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
+43
-1
@@ -23,6 +23,7 @@ Extensions can:
|
||||
- serve files from one configured local directory at `/extensions/...`
|
||||
- inject configured same-origin stylesheets into `<head>`
|
||||
- inject configured same-origin scripts before `</body>`
|
||||
- read a local JSON manifest that lists bundled scripts/styles to inject
|
||||
- call the normal WebUI APIs available to the browser session
|
||||
|
||||
Extensions cannot, by themselves:
|
||||
@@ -53,14 +54,53 @@ export HERMES_WEBUI_EXTENSION_SCRIPT_URLS=/extensions/runtime.js,/extensions/app
|
||||
export HERMES_WEBUI_EXTENSION_STYLESHEET_URLS=/extensions/base.css,/extensions/theme.css
|
||||
```
|
||||
|
||||
For bundled or multi-extension installs, you may list assets in a manifest file
|
||||
inside `HERMES_WEBUI_EXTENSION_DIR` instead of maintaining long comma-separated
|
||||
environment variables:
|
||||
|
||||
```bash
|
||||
cat > ~/.hermes/webui-extension-bundle/extensions.json <<'JSON'
|
||||
{
|
||||
"extensions": [
|
||||
{
|
||||
"id": "templates",
|
||||
"scripts": ["templates/templates.js"],
|
||||
"stylesheets": ["templates/templates.css"]
|
||||
},
|
||||
{
|
||||
"id": "sidebar-tools",
|
||||
"scripts": ["sidebar-tools/sidebar-tools.js"],
|
||||
"stylesheets": ["sidebar-tools/sidebar-tools.css"]
|
||||
}
|
||||
]
|
||||
}
|
||||
JSON
|
||||
|
||||
HERMES_WEBUI_EXTENSION_DIR=~/.hermes/webui-extension-bundle \
|
||||
HERMES_WEBUI_EXTENSION_MANIFEST=extensions.json \
|
||||
./start.sh
|
||||
```
|
||||
|
||||
Manifest entries use the same URL safety rules as the environment variables.
|
||||
Bare relative entries such as `templates/templates.js` resolve to
|
||||
`/extensions/templates/templates.js`; absolute same-origin entries such as
|
||||
`/extensions/shared.js` or `/static/theme.css` are also accepted. A manifest may
|
||||
be an object with top-level `scripts` / `stylesheets`, an object with an
|
||||
`extensions` array, or a top-level array of extension objects. Disabled entries
|
||||
may be kept in the manifest with the JSON boolean `"enabled": false`. Explicit
|
||||
`HERMES_WEBUI_EXTENSION_SCRIPT_URLS` and
|
||||
`HERMES_WEBUI_EXTENSION_STYLESHEET_URLS` still work and are appended after
|
||||
manifest assets, with duplicates ignored.
|
||||
|
||||
## URL rules
|
||||
|
||||
Injected asset URLs are deliberately restricted:
|
||||
|
||||
- must be same-origin paths
|
||||
- must start with `/extensions/` or `/static/`
|
||||
- must start with `/extensions/` or `/static/` after manifest normalization
|
||||
- must not include a URL scheme, host, fragment, quote, angle bracket, newline,
|
||||
NUL byte, or backslash
|
||||
- must not contain dot-segments or dotfiles after percent-decoding
|
||||
|
||||
Allowed examples:
|
||||
|
||||
@@ -101,6 +141,8 @@ The static handler is sandboxed:
|
||||
- dotfiles and dot-directories are not served
|
||||
- symlinks that resolve outside the extension directory are rejected
|
||||
- missing or invalid extension directories behave as disabled
|
||||
- manifest paths must be relative files inside the configured extension directory
|
||||
- malformed, missing, or oversized manifests are ignored without enabling unsafe URLs
|
||||
- failures return a generic 404 without exposing local filesystem paths
|
||||
|
||||
## Security notes
|
||||
|
||||
@@ -7,8 +7,21 @@ The extension surface must stay deliberately small and safe:
|
||||
- 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):
|
||||
@@ -127,6 +140,344 @@ def test_extension_route_remains_behind_webui_auth(monkeypatch):
|
||||
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_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()
|
||||
|
||||
Reference in new issue
Block a user