mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-24 18:50:15 +00:00
210 lines
7.5 KiB
Python
210 lines
7.5 KiB
Python
"""Tests for opt-in WebUI extension hooks.
|
|
|
|
The extension surface must stay deliberately small and safe:
|
|
- disabled unless configured by environment
|
|
- same-origin script/style URLs only
|
|
- no filesystem path leakage in public config
|
|
- static assets sandboxed to the configured extension directory
|
|
"""
|
|
|
|
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_extension_config_disabled_by_default(monkeypatch):
|
|
monkeypatch.delenv("HERMES_WEBUI_EXTENSION_DIR", raising=False)
|
|
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": False,
|
|
"script_urls": [],
|
|
"stylesheet_urls": [],
|
|
}
|
|
|
|
|
|
def test_extension_config_accepts_only_safe_same_origin_urls(tmp_path, monkeypatch):
|
|
root = tmp_path / "extensions"
|
|
root.mkdir()
|
|
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root))
|
|
monkeypatch.setenv(
|
|
"HERMES_WEBUI_EXTENSION_SCRIPT_URLS",
|
|
", ".join(
|
|
[
|
|
"/extensions/app.js",
|
|
"https://example.com/evil.js",
|
|
"//example.com/evil.js",
|
|
"javascript:alert(1)",
|
|
"/api/session",
|
|
"/extensions/../api/session",
|
|
"/extensions/%2e%2e/api/session",
|
|
"/extensions/%252e%252e/api/session",
|
|
"/static/../api/session",
|
|
]
|
|
),
|
|
)
|
|
monkeypatch.setenv(
|
|
"HERMES_WEBUI_EXTENSION_STYLESHEET_URLS",
|
|
"/extensions/app.css, /static/theme.css, data:text/css,body{}",
|
|
)
|
|
|
|
from api.extensions import get_extension_config
|
|
|
|
assert get_extension_config() == {
|
|
"enabled": True,
|
|
"script_urls": ["/extensions/app.js"],
|
|
"stylesheet_urls": ["/extensions/app.css", "/static/theme.css"],
|
|
}
|
|
|
|
|
|
def test_index_html_injection_escapes_urls_and_preserves_disabled_default(tmp_path, monkeypatch):
|
|
monkeypatch.delenv("HERMES_WEBUI_EXTENSION_DIR", raising=False)
|
|
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_SCRIPT_URLS", "/extensions/app.js")
|
|
|
|
from api.extensions import inject_extension_tags
|
|
|
|
html = "<html><head></head><body><main></main></body></html>"
|
|
assert inject_extension_tags(html) == html
|
|
|
|
root = tmp_path / "extensions"
|
|
root.mkdir()
|
|
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root))
|
|
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_SCRIPT_URLS", "/extensions/app.js?v=1&mode=dev")
|
|
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_STYLESHEET_URLS", "/extensions/app.css")
|
|
|
|
injected = inject_extension_tags(html)
|
|
|
|
assert '<link rel="stylesheet" href="/extensions/app.css">' in injected
|
|
assert '<script src="/extensions/app.js?v=1&mode=dev" defer></script>' in injected
|
|
assert injected.index("/extensions/app.css") < injected.index("</head>")
|
|
assert injected.index("/extensions/app.js") < injected.index("</body>")
|
|
|
|
|
|
def test_extension_route_remains_behind_webui_auth(monkeypatch):
|
|
monkeypatch.setenv("HERMES_WEBUI_PASSWORD", "test-password")
|
|
|
|
from api.auth import check_auth
|
|
|
|
extension = FakeHandler()
|
|
# 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?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", query="")) is True
|
|
|
|
|
|
def test_extension_static_serving_is_sandboxed(tmp_path, monkeypatch):
|
|
root = tmp_path / "extensions"
|
|
root.mkdir()
|
|
(root / "app.js").write_text("window.extensionLoaded = true;", encoding="utf-8")
|
|
(root / ".secret").write_text("do not serve", encoding="utf-8")
|
|
outside = tmp_path / "outside.txt"
|
|
outside.write_text("outside", encoding="utf-8")
|
|
|
|
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root))
|
|
|
|
from api.extensions import serve_extension_static
|
|
|
|
ok = FakeHandler()
|
|
assert serve_extension_static(ok, SimpleNamespace(path="/extensions/app.js")) is True
|
|
assert ok.status == 200
|
|
assert ok.header("Content-Type") == "application/javascript; charset=utf-8"
|
|
assert bytes(ok.body) == b"window.extensionLoaded = true;"
|
|
|
|
traversal = FakeHandler()
|
|
assert serve_extension_static(traversal, SimpleNamespace(path="/extensions/../outside.txt")) is True
|
|
assert traversal.status == 404
|
|
|
|
encoded_traversal = FakeHandler()
|
|
assert serve_extension_static(encoded_traversal, SimpleNamespace(path="/extensions/%2e%2e/outside.txt")) is True
|
|
assert encoded_traversal.status == 404
|
|
|
|
dotfile = FakeHandler()
|
|
assert serve_extension_static(dotfile, SimpleNamespace(path="/extensions/.secret")) is True
|
|
assert dotfile.status == 404
|
|
|
|
|
|
def test_extension_static_serving_fails_closed_when_disabled_or_unreadable(tmp_path, monkeypatch):
|
|
missing_root = tmp_path / "missing"
|
|
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(missing_root))
|
|
|
|
from api.extensions import serve_extension_static
|
|
|
|
disabled = FakeHandler()
|
|
assert serve_extension_static(disabled, SimpleNamespace(path="/extensions/app.js")) is True
|
|
assert disabled.status == 404
|
|
|
|
root = tmp_path / "extensions"
|
|
root.mkdir()
|
|
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root))
|
|
(root / "nested").mkdir()
|
|
(root / "nested" / "app.js").write_text("ok", encoding="utf-8")
|
|
|
|
encoded_slash_traversal = FakeHandler()
|
|
assert serve_extension_static(
|
|
encoded_slash_traversal,
|
|
SimpleNamespace(path="/extensions/nested%2f..%2f..%2foutside.txt"),
|
|
) is True
|
|
assert encoded_slash_traversal.status == 404
|
|
|
|
encoded_backslash = FakeHandler()
|
|
assert serve_extension_static(encoded_backslash, SimpleNamespace(path="/extensions/nested%5capp.js")) is True
|
|
assert encoded_backslash.status == 404
|
|
|
|
|
|
def test_extension_static_serving_rejects_symlink_escape(tmp_path, monkeypatch):
|
|
root = tmp_path / "extensions"
|
|
root.mkdir()
|
|
outside = tmp_path / "outside.txt"
|
|
outside.write_text("outside", encoding="utf-8")
|
|
symlink = root / "outside-link.txt"
|
|
|
|
try:
|
|
symlink.symlink_to(outside)
|
|
except OSError:
|
|
# Some platforms/filesystems disallow symlink creation. The path-safety
|
|
# behavior is still covered by traversal tests above.
|
|
return
|
|
|
|
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root))
|
|
|
|
from api.extensions import serve_extension_static
|
|
|
|
escaped = FakeHandler()
|
|
assert serve_extension_static(escaped, SimpleNamespace(path="/extensions/outside-link.txt")) is True
|
|
assert escaped.status == 404
|