stage-265: test fix + CHANGELOG for v0.50.265

This commit is contained in:
nesquena-hermes
2026-05-02 03:42:58 +00:00
parent 3de70c52fb
commit 73cb3c1948
2 changed files with 15 additions and 3 deletions
+9
View File
@@ -1,5 +1,14 @@
# Hermes Web UI -- Changelog
## [v0.50.265] — 2026-05-02
### Added
- **Opt-in WebUI extension hooks** (#1445) — adds a deliberately-small, self-hosted extension surface for administrators who want to inject local CSS/JS into the WebUI shell without forking the core repo. Disabled by default; activates only when `HERMES_WEBUI_EXTENSION_DIR` points to an existing directory. Three env vars expose the surface: `HERMES_WEBUI_EXTENSION_DIR` (filesystem root for served assets), `HERMES_WEBUI_EXTENSION_SCRIPT_URLS` (comma-separated same-origin script URLs to inject before `</body>`), `HERMES_WEBUI_EXTENSION_STYLESHEET_URLS` (same-origin stylesheet URLs to inject before `</head>`). New `/extensions/...` static route is auth-gated (NOT in `PUBLIC_PATHS`, unlike `/static/...`) so administrator-supplied code only runs for authenticated sessions. URL validation rejects external schemes, protocol-relative URLs, fragments, traversal (raw + percent-encoded + double-encoded), control characters, quotes, and angle brackets. Filesystem serving sandboxes paths under the configured root via `Path.resolve()` + `relative_to()`, rejects dotfiles, dot-directories, encoded backslashes, and symlink escapes. CSP unchanged — extensions live at same origin so existing `'self'` directive covers them. 7 regression tests in `tests/test_extension_hooks.py` pin the disabled-by-default contract, URL validation against external/protocol-relative/javascript:/data:/API/encoded-traversal, HTML escaping during injection, the auth-gate vs public-static distinction, sandboxed static serving, fail-closed when disabled or unreadable, and symlink-escape rejection. Documentation in `docs/EXTENSIONS.md` (204 lines) covers extension authoring guidance for SPA-style additions, including avoiding destructive DOM mutations like replacing `main.innerHTML`. **Trust model**: extensions are intentionally administrator-controlled — JS injected this way runs in the WebUI origin and can call any authenticated API the logged-in browser session can. The PR explicitly does NOT introduce remote extension loading, a plugin marketplace, Python plugin execution, manifests, a browser-facing config endpoint, or new dependencies. (`api/extensions.py`, `api/routes.py`, `docs/EXTENSIONS.md`, `tests/test_extension_hooks.py`, `README.md`) @ryansombraio — PR #1445
### Fixed (stage-merge)
- Test-only fix: `tests/test_extension_hooks.py::test_extension_route_remains_behind_webui_auth` was using `SimpleNamespace(path=...)` without a `.query` attribute, but `api/auth.py:check_auth` (since v0.50.258's multi-param `?next=` encoding fix) accesses `parsed.query` when constructing the redirect Location. Added `query=""` to the test's namespace and updated the expected Location to include the encoded `?next=` parameter. No behavior change.
## [v0.50.264] — 2026-05-02
### Added
+6 -3
View File
@@ -114,14 +114,17 @@ def test_extension_route_remains_behind_webui_auth(monkeypatch):
from api.auth import check_auth
extension = FakeHandler()
assert check_auth(extension, SimpleNamespace(path="/extensions/app.js")) is False
# 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"
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")) is True
assert check_auth(static, SimpleNamespace(path="/static/ui.js", query="")) is True
def test_extension_static_serving_is_sandboxed(tmp_path, monkeypatch):