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.
177 lines
6.6 KiB
Python
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")
|