From 4ee93684640eebc457d2ba4be3fab349617ee753 Mon Sep 17 00:00:00 2001 From: nesquena-hermes <226517000+nesquena-hermes@users.noreply.github.com> Date: Sat, 2 May 2026 03:49:40 +0000 Subject: [PATCH] Opus pre-release follow-ups for PR #1445 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit REQUIRED: - _fully_unquote_path range(3) -> range(10) — defense-in-depth so quadruple- encoded .. is rejected by validator instead of slipping through (not exploitable but contract violation) - docs/EXTENSIONS.md trust-model callout moved to top of file with explicit 'don't enable in untrusted env / don't point at user-writable dir' guidance NICE-TO-HAVE (taken since Nathan asked for all fixes big and small): - URL list cap at _MAX_URL_LIST=32 to avoid pathological rendering - One-shot WARNING log for rejected URLs (silent drop now visible to admin) - One-shot WARNING log for URL list truncation - MIME map: ttf (font/ttf), otf (font/otf), wasm (application/wasm) 5 regression tests in tests/test_pr1445_opus_followups.py pin all invariants. --- CHANGELOG.md | 8 +- api/extensions.py | 50 +++++++- docs/EXTENSIONS.md | 8 ++ tests/test_pr1445_opus_followups.py | 176 ++++++++++++++++++++++++++++ 4 files changed, 237 insertions(+), 5 deletions(-) create mode 100644 tests/test_pr1445_opus_followups.py diff --git a/CHANGELOG.md b/CHANGELOG.md index d62ebb8f..4c7f899d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,8 +5,12 @@ ### 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 ``), `HERMES_WEBUI_EXTENSION_STYLESHEET_URLS` (same-origin stylesheet URLs to inject before ``). 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. +### Fixed (Opus pre-release advisor) +- **`_fully_unquote_path` iteration cap raised from 3 to 10** — Opus advisor noted that quadruple-encoded `..` (`%2525252e%2525252e`) collapsed to `%2e%2e` after 3 iterations and slipped through the URL-injection validator. Not exploitable in practice (downstream `Path` doesn't decode `%2e` either, so the literal directory `%2e%2e` won't exist) but the validator's documented contract is "URLs must point to `/extensions/` or `/static/`," and a malformed URL that's neither cleanly that nor cleanly rejected violates the contract. Iteration cap is now 10 (URL strings stabilize in <5 iterations in practice; the cap is defensive). (`api/extensions.py`) +- **Trust-model callout at top of `docs/EXTENSIONS.md`** — moved the strongest trust-model warning ("extensions execute with full WebUI session authority") from the middle of the doc to a blockquote callout at the top, right after the lead paragraph. A casual operator skimming for "should I enable this?" now sees the hard truth before the friendly intro. Also adds explicit "do not point `HERMES_WEBUI_EXTENSION_DIR` at a user-writable directory" guidance. (`docs/EXTENSIONS.md`) +- **URL list cap (32 entries) + reject-URL logging** — caps configured URL lists at 32 entries to avoid pathological page rendering when a misconfigured env var ships thousands of URLs. Also logs a one-shot warning per process for each rejected URL (e.g. when an admin typos `https://...` and the validator drops it as external) so the silent-failure mode of "extension just doesn't load" produces a log signal an admin can find. (`api/extensions.py`) +- **MIME map expansion** — adds `ttf` (`font/ttf`), `otf` (`font/otf`), and `wasm` (`application/wasm`) to the served-MIME table. `.wasm` specifically would fail to instantiate in Chrome served as `text/plain`; the others are ergonomic for older font formats. (`api/extensions.py`) +- **5 regression tests** in `tests/test_pr1445_opus_followups.py` pin the new invariants: quadruple-encoded `..` collapses correctly, the same URL is now rejected by the validator, URL list caps at the configured max with a warning log, rejected URLs log exactly once per process, and the expanded MIME map serves `.ttf`/`.otf`/`.wasm` with the correct Content-Type without charset suffixes for binary types. (`tests/test_pr1445_opus_followups.py`) ## [v0.50.264] — 2026-05-02 diff --git a/api/extensions.py b/api/extensions.py index 7466aed5..5a862b70 100644 --- a/api/extensions.py +++ b/api/extensions.py @@ -6,6 +6,7 @@ It is disabled by default and never executes or fetches third-party URLs. """ import html +import logging import os from pathlib import Path from typing import Dict, List, Optional @@ -13,6 +14,18 @@ from urllib.parse import unquote, urlsplit from api.helpers import _security_headers, j +_log = logging.getLogger(__name__) + +# Sane bound on configured URLs — real extensions ship 1-3 files. Higher values +# typically indicate a misconfiguration (one giant unsplit string, or a runaway +# generator script that wrote an env-var template without filtering). Capping +# avoids rendering tens of thousands of