Files
hermes-webui/tests/test_pr1445_opus_followups.py
nesquena-hermes 4ee9368464 Opus pre-release follow-ups for PR #1445
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.
2026-05-02 03:49:40 +00:00

177 lines
6.6 KiB
Python

"""Opus pre-release follow-up tests for stage-265 (PR #1445 extension hooks).
These tests pin the defense-in-depth additions from the Opus advisor review:
- `_fully_unquote_path` iterates up to 10 times (catches quadruple-encoded ..)
- `_read_url_list` caps at `_MAX_URL_LIST` (32) entries
- `_read_url_list` logs once per rejected URL
- MIME map covers `ttf`, `otf`, `wasm` for modern font/wasm assets
"""
import logging
from pathlib import Path
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_fully_unquote_handles_quadruple_encoded(monkeypatch):
"""Quadruple-encoded `..` (`%2525252e%2525252e`) must collapse to literal
`..` so the segment-level safety check rejects it. The original 3-iteration
cap stopped at `%2e%2e` and would have accepted the URL into the validator.
"""
from api.extensions import _fully_unquote_path
# Plain percent-encoding stops at `..` after 1 unquote
assert _fully_unquote_path("/extensions/%2e%2e/api/session") == "/extensions/../api/session"
# Double-encoded after 2 unquotes
assert _fully_unquote_path("/extensions/%252e%252e/api/session") == "/extensions/../api/session"
# Triple-encoded after 3 unquotes
assert _fully_unquote_path("/extensions/%25252e%25252e/api/session") == "/extensions/../api/session"
# Quadruple-encoded after 4 unquotes — the case that slipped through the
# original `range(3)` and reached the validator unchanged
assert _fully_unquote_path("/extensions/%2525252e%2525252e/api/session") == "/extensions/../api/session"
def test_quadruple_encoded_traversal_url_now_rejected(tmp_path, monkeypatch):
"""End-to-end: quadruple-encoded `..` in a configured URL is rejected
by the validator instead of slipping through. Pre-Opus this passed.
"""
root = tmp_path / "extensions"
root.mkdir()
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root))
monkeypatch.setenv(
"HERMES_WEBUI_EXTENSION_SCRIPT_URLS",
"/extensions/%2525252e%2525252e/api/session, /extensions/legit.js",
)
from api.extensions import get_extension_config
config = get_extension_config()
# Only the legit URL should pass validation; the quadruple-encoded
# traversal must be filtered out
assert config["script_urls"] == ["/extensions/legit.js"]
def test_url_list_caps_at_max(tmp_path, monkeypatch):
"""Configured URL lists cap at _MAX_URL_LIST entries to avoid pathological
rendering when a misconfigured env var ships thousands of URLs.
"""
root = tmp_path / "extensions"
root.mkdir()
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root))
# Build 100 valid URLs
urls = ", ".join(f"/extensions/script{i}.js" for i in range(100))
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_SCRIPT_URLS", urls)
from api.extensions import get_extension_config, _MAX_URL_LIST
config = get_extension_config()
assert len(config["script_urls"]) == _MAX_URL_LIST
# First N kept (insertion order)
assert config["script_urls"][0] == "/extensions/script0.js"
assert config["script_urls"][-1] == f"/extensions/script{_MAX_URL_LIST - 1}.js"
def test_url_list_logs_rejected_urls_once(tmp_path, monkeypatch, caplog):
"""A misconfigured URL must produce a one-shot warning so an admin who
typos `https://...` (rejected as external) sees a signal in logs instead
of just a silently-not-loading extension.
"""
root = tmp_path / "extensions"
root.mkdir()
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root))
monkeypatch.setenv(
"HERMES_WEBUI_EXTENSION_SCRIPT_URLS",
"https://evil.example.com/x.js, /extensions/legit.js",
)
# Reset the per-process warning cache so the test doesn't accidentally
# depend on state from other tests in the same run
from api.extensions import _warned_urls
_warned_urls.clear()
caplog.set_level(logging.WARNING, logger="api.extensions")
from api.extensions import get_extension_config
config = get_extension_config()
assert config["script_urls"] == ["/extensions/legit.js"]
# The external URL must surface as a warning in the log
assert any(
"Rejected extension URL" in record.message
and "evil.example.com" in record.message
for record in caplog.records
)
# Second call within the same process must not re-log (one-shot)
caplog.clear()
config2 = get_extension_config()
assert config2["script_urls"] == ["/extensions/legit.js"]
rejection_records = [
r for r in caplog.records
if "Rejected extension URL" in r.message and "evil.example.com" in r.message
]
assert rejection_records == [], (
"Repeated invalid URL should not re-log on every config read"
)
def test_expanded_mime_map_serves_fonts_and_wasm(tmp_path, monkeypatch):
"""`ttf`, `otf`, and `wasm` extensions must serve with the right
Content-Type so browsers don't reject (especially `.wasm`, which Chrome
refuses to instantiate when served as `text/plain`).
"""
root = tmp_path / "extensions"
root.mkdir()
(root / "font.ttf").write_bytes(b"fake ttf binary")
(root / "font.otf").write_bytes(b"fake otf binary")
(root / "module.wasm").write_bytes(b"\x00asm" + b"\x01" * 8)
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root))
from api.extensions import serve_extension_static
ttf = FakeHandler()
assert serve_extension_static(ttf, SimpleNamespace(path="/extensions/font.ttf")) is True
assert ttf.status == 200
assert ttf.header("Content-Type") == "font/ttf"
otf = FakeHandler()
assert serve_extension_static(otf, SimpleNamespace(path="/extensions/font.otf")) is True
assert otf.status == 200
assert otf.header("Content-Type") == "font/otf"
wasm = FakeHandler()
assert serve_extension_static(wasm, SimpleNamespace(path="/extensions/module.wasm")) is True
assert wasm.status == 200
assert wasm.header("Content-Type") == "application/wasm"
# Binary types must NOT have a charset suffix
assert "charset" not in wasm.header("Content-Type")