Files
hermes-webui/tests/test_extension_hooks.py
nesquena-hermes d257e5f36c fix(security): gate embedded-terminal endpoints to local origins when auth disabled
The embedded workspace terminal spawns a PTY shell that runs arbitrary
commands as the server-process user. check_auth() returns True
unconditionally when no password/passkey is configured (the default
out-of-the-box state), so without a network-scope gate the terminal
endpoints were reachable by any unauthenticated caller able to hit the
port — which on a passwordless public bind is remote code execution.

Apply the same local-origin gate the onboarding/bootstrap endpoints use
(_onboarding_gate_allows) to /api/terminal/{start,input,resize,close} and
/api/terminal/output: with auth disabled, accept only loopback/private
origins, ignore spoofable X-Forwarded-For/X-Real-IP unless
HERMES_WEBUI_TRUST_FORWARDED_FOR=1, and honor HERMES_WEBUI_ONBOARDING_OPEN=1
as the explicit opt-out for a deliberately-exposed server. Auth-enabled
servers (cookie already verified upstream) and genuine same-host clients
are unaffected.

Also fixes a latent test-isolation leak in
test_extension_route_remains_behind_webui_auth: it set HERMES_WEBUI_PASSWORD
but never invalidated the process-wide password-hash cache, so its result
depended on suite execution order (exposed when the new test file shifted
ordering). Invalidate before+after so it reads the env var deterministically.

12 new gate tests in tests/test_cvd3_terminal_local_origin_gate.py.
2026-07-01 00:40:25 +00:00

716 lines
25 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
"""
import logging
from types import SimpleNamespace
import pytest
@pytest.fixture(autouse=True)
def _clear_extension_env(monkeypatch):
for name in (
"HERMES_WEBUI_EXTENSION_DIR",
"HERMES_WEBUI_EXTENSION_MANIFEST",
"HERMES_WEBUI_EXTENSION_SCRIPT_URLS",
"HERMES_WEBUI_EXTENSION_STYLESHEET_URLS",
):
monkeypatch.delenv(name, raising=False)
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(tmp_path, 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)
# Point the managed state dir at an empty temp dir so the default extension
# root does not exist yet — config stays disabled until the first install.
import api.extensions as extensions
monkeypatch.setattr(extensions, "_extension_state_dir", lambda: tmp_path)
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&amp;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_settings_runtime_config_injects_before_extension_scripts(tmp_path, monkeypatch):
root = tmp_path / "extensions"
root.mkdir()
(root / "manifest.json").write_text(
"""
{
"extensions": [
{
"id": "settings-ok",
"scripts": ["settings-ok.js"],
"permissions": {"storage": {"owned": true}},
"settings_schema": [
{"key": "flag", "type": "boolean", "default": true}
]
}
]
}
""",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root))
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_MANIFEST", "manifest.json")
from api.extensions import inject_extension_tags
injected = inject_extension_tags("<html><head></head><body></body></html>")
assert "window.__HERMES_EXTENSION_CONFIG__" in injected
assert "window.HermesExtensionSettings.primeFromStatus(window.__HERMES_EXTENSION_CONFIG__)" in injected
assert '"storage_owned":true' in injected
assert '"settings_schema":[{"key":"flag","type":"boolean"' in injected
assert injected.index("window.__HERMES_EXTENSION_CONFIG__") < injected.index("/extensions/settings-ok.js")
def test_extension_settings_only_manifest_still_injects_runtime_config(tmp_path, monkeypatch):
root = tmp_path / "extensions"
root.mkdir()
(root / "manifest.json").write_text(
"""
{
"extensions": [
{
"id": "settings-only",
"permissions": {"storage": {"owned": true}},
"settings_schema": [
{"key": "flag", "type": "boolean", "default": true}
]
}
]
}
""",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root))
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_MANIFEST", "manifest.json")
from api.extensions import inject_extension_tags
injected = inject_extension_tags("<html><head></head><body></body></html>")
assert "window.__HERMES_EXTENSION_CONFIG__" in injected
assert '"id":"settings-only"' in injected
assert injected.index("window.__HERMES_EXTENSION_CONFIG__") < 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, _invalidate_password_hash_cache
# The password hash is cached process-wide (PBKDF2 is ~1s/call). Invalidate
# before so this test reads the just-set env var rather than a stale None
# cached by an earlier auth-disabled test, and again in finally so the hash
# computed here can't leak into a later test that expects auth disabled.
# Without this the test's result depends on suite execution order.
_invalidate_password_hash_cache()
try:
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
finally:
_invalidate_password_hash_cache()
def test_extension_manifest_adds_bundled_assets_before_env_urls(tmp_path, monkeypatch):
root = tmp_path / "extensions"
root.mkdir()
(root / "manifest.json").write_text(
"""
{
"scripts": ["runtime.js"],
"stylesheets": ["base.css"],
"extensions": [
{
"id": "templates",
"scripts": ["templates/app.js", "/extensions/shared.js"],
"stylesheets": ["templates/app.css"]
},
{
"id": "disabled",
"enabled": false,
"scripts": ["disabled.js"],
"stylesheets": ["disabled.css"]
}
]
}
""",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root))
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_MANIFEST", "manifest.json")
monkeypatch.setenv(
"HERMES_WEBUI_EXTENSION_SCRIPT_URLS",
"/extensions/templates/app.js, /extensions/env-only.js",
)
monkeypatch.setenv(
"HERMES_WEBUI_EXTENSION_STYLESHEET_URLS",
"/extensions/env-only.css",
)
from api.extensions import get_extension_config
assert get_extension_config() == {
"enabled": True,
"script_urls": [
"/extensions/runtime.js",
"/extensions/templates/app.js",
"/extensions/shared.js",
"/extensions/env-only.js",
],
"stylesheet_urls": [
"/extensions/base.css",
"/extensions/templates/app.css",
"/extensions/env-only.css",
],
"extensions": [
{
"id": "templates",
"name": "templates",
"storage_owned": False,
"settings_schema": [],
},
],
}
def test_extension_manifest_relative_assets_resolve_from_manifest_directory(tmp_path, monkeypatch):
root = tmp_path / "extensions"
root.mkdir()
ext_dir = root / "desktop-companion"
ext_dir.mkdir()
(ext_dir / "manifest.json").write_text(
"""
{
"extensions": [
{
"id": "desktop-companion",
"scripts": ["assets/companion-adapter.js"],
"stylesheets": ["assets/companion-adapter.css"]
}
]
}
""",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root))
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_MANIFEST", "desktop-companion/manifest.json")
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": True,
"script_urls": ["/extensions/desktop-companion/assets/companion-adapter.js"],
"stylesheet_urls": ["/extensions/desktop-companion/assets/companion-adapter.css"],
"extensions": [
{
"id": "desktop-companion",
"name": "desktop-companion",
"storage_owned": False,
"settings_schema": [],
}
],
}
def test_extension_manifest_reuses_url_safety_rules(tmp_path, monkeypatch):
monkeypatch.delenv("HERMES_WEBUI_EXTENSION_SCRIPT_URLS", raising=False)
monkeypatch.delenv("HERMES_WEBUI_EXTENSION_STYLESHEET_URLS", raising=False)
root = tmp_path / "extensions"
root.mkdir()
(root / "bundle.json").write_text(
"""
{
"scripts": [
"safe.js",
"../escape.js",
"/api/session",
"https://example.com/evil.js",
"/extensions/%252e%252e/api/session"
],
"stylesheets": ["safe.css", "nested/../escape.css"]
}
""",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root))
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_MANIFEST", "bundle.json")
from api.extensions import get_extension_config
assert get_extension_config() == {
"enabled": True,
"script_urls": ["/extensions/safe.js"],
"stylesheet_urls": ["/extensions/safe.css"],
}
def test_extension_manifest_deeply_nested_json_fails_safe(tmp_path, monkeypatch):
"""A <=64KB but deeply-nested manifest makes json.loads raise RecursionError.
It must fail safe (empty lists) — NOT escape and 503 every page load."""
monkeypatch.delenv("HERMES_WEBUI_EXTENSION_SCRIPT_URLS", raising=False)
monkeypatch.delenv("HERMES_WEBUI_EXTENSION_STYLESHEET_URLS", raising=False)
root = tmp_path / "extensions"
root.mkdir()
# ~6000 nested arrays: well under 64KB but blows the recursion limit in json.loads.
depth = 6000
payload = "[" * depth + "]" * depth
assert len(payload.encode("utf-8")) < 64 * 1024
(root / "deep.json").write_text(payload, encoding="utf-8")
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root))
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_MANIFEST", "deep.json")
from api.extensions import get_extension_config
# Must not raise; must fall back to no manifest assets.
cfg = get_extension_config()
assert cfg["script_urls"] == []
assert cfg["stylesheet_urls"] == []
def test_extension_manifest_path_must_stay_inside_extension_root(tmp_path, monkeypatch):
monkeypatch.delenv("HERMES_WEBUI_EXTENSION_STYLESHEET_URLS", raising=False)
root = tmp_path / "extensions"
root.mkdir()
outside = tmp_path / "outside.json"
outside.write_text('{"scripts":["outside.js"]}', encoding="utf-8")
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root))
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_MANIFEST", "../outside.json")
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_SCRIPT_URLS", "/extensions/env.js")
from api.extensions import get_extension_config
assert get_extension_config() == {
"enabled": True,
"script_urls": ["/extensions/env.js"],
"stylesheet_urls": [],
}
def test_extension_manifest_url_list_shares_cap_with_env_urls(tmp_path, monkeypatch):
monkeypatch.delenv("HERMES_WEBUI_EXTENSION_STYLESHEET_URLS", raising=False)
root = tmp_path / "extensions"
root.mkdir()
scripts = ",".join(f'"script{i}.js"' for i in range(40))
(root / "manifest.json").write_text(f'{{"scripts":[{scripts}]}}', encoding="utf-8")
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root))
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_MANIFEST", "manifest.json")
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_SCRIPT_URLS", "/extensions/env.js")
from api.extensions import _MAX_URL_LIST, get_extension_config
config = get_extension_config()
assert len(config["script_urls"]) == _MAX_URL_LIST
assert config["script_urls"][0] == "/extensions/script0.js"
assert config["script_urls"][-1] == f"/extensions/script{_MAX_URL_LIST - 1}.js"
assert "/extensions/env.js" not in config["script_urls"]
def test_extension_manifest_logs_invalid_json_separately_from_oversize(tmp_path, monkeypatch, caplog):
root = tmp_path / "extensions"
root.mkdir()
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root))
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_MANIFEST", "manifest.json")
(root / "manifest.json").write_text('{"scripts": [', encoding="utf-8")
from api.extensions import get_extension_config
caplog.set_level(logging.WARNING, logger="api.extensions")
assert get_extension_config() == {
"enabled": True,
"script_urls": [],
"stylesheet_urls": [],
}
assert any("not valid JSON" in record.message for record in caplog.records)
assert not any("could not be read" in record.message for record in caplog.records)
assert not any("exceeds" in record.message for record in caplog.records)
def test_extension_manifest_logs_invalid_utf8_as_unreadable(tmp_path, monkeypatch, caplog):
root = tmp_path / "extensions"
root.mkdir()
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root))
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_MANIFEST", "manifest.json")
(root / "manifest.json").write_bytes(b"{\xff}")
from api.extensions import get_extension_config
caplog.set_level(logging.WARNING, logger="api.extensions")
assert get_extension_config() == {
"enabled": True,
"script_urls": [],
"stylesheet_urls": [],
}
assert any("could not be read" in record.message for record in caplog.records)
assert not any("exceeds" in record.message for record in caplog.records)
def test_extension_manifest_logs_oversize_distinctly(tmp_path, monkeypatch, caplog):
root = tmp_path / "extensions"
root.mkdir()
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root))
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_MANIFEST", "manifest.json")
from api import extensions
(root / "manifest.json").write_text(
'{"scripts":["pwn.js"]}' + (" " * extensions._MAX_MANIFEST_BYTES),
encoding="utf-8",
)
caplog.set_level(logging.WARNING, logger="api.extensions")
assert extensions.get_extension_config() == {
"enabled": True,
"script_urls": [],
"stylesheet_urls": [],
}
assert any("exceeds" in record.message for record in caplog.records)
assert not any("not valid JSON" in record.message for record in caplog.records)
def test_extension_manifest_reads_only_bounded_size(tmp_path, monkeypatch):
root = tmp_path / "extensions"
root.mkdir()
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root))
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_MANIFEST", "manifest.json")
from api import extensions
# If the bounded read/cap check is removed, this parses as valid JSON and
# would inject /extensions/pwn.js. The trailing padding makes it oversize.
oversize = '{"scripts":["pwn.js"]}' + (" " * extensions._MAX_MANIFEST_BYTES)
(root / "manifest.json").write_text(oversize, encoding="utf-8")
assert extensions.get_extension_config() == {
"enabled": True,
"script_urls": [],
"stylesheet_urls": [],
}
def test_extension_manifest_multibyte_payload_is_bounded_by_bytes(tmp_path, monkeypatch):
root = tmp_path / "extensions"
root.mkdir()
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root))
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_MANIFEST", "manifest.json")
from api import extensions
oversize_multibyte = '{"scripts":["pwn.js"]}' + ("€" * extensions._MAX_MANIFEST_BYTES)
(root / "manifest.json").write_text(oversize_multibyte, encoding="utf-8")
assert extensions.get_extension_config() == {
"enabled": True,
"script_urls": [],
"stylesheet_urls": [],
}
def test_extension_manifest_cap_warning_logs_once_across_many_entries(tmp_path, monkeypatch, caplog):
root = tmp_path / "extensions"
root.mkdir()
entries = [
{
"id": f"ext{i}",
"scripts": [f"script{i}.js"],
"stylesheets": [f"style{i}.css"],
}
for i in range(40)
]
(root / "manifest.json").write_text(
__import__("json").dumps({"extensions": entries}), encoding="utf-8"
)
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root))
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_MANIFEST", "manifest.json")
from api.extensions import _MAX_URL_LIST, _warned_urls, get_extension_config
_warned_urls.clear()
caplog.set_level(logging.WARNING, logger="api.extensions")
config = get_extension_config()
assert len(config["script_urls"]) == _MAX_URL_LIST
assert len(config["stylesheet_urls"]) == _MAX_URL_LIST
truncation_records = [
record for record in caplog.records
if "truncated at" in record.message and "Extension URL list" in record.message
]
assert len(truncation_records) == 2
assert any("manifest:scripts" in record.message for record in truncation_records)
assert any("manifest:stylesheets" in record.message for record in truncation_records)
def test_extension_manifest_ignores_non_list_asset_fields(tmp_path, monkeypatch):
root = tmp_path / "extensions"
root.mkdir()
(root / "manifest.json").write_text(
"""
{
"scripts": "app.js",
"stylesheets": {"href": "app.css"},
"extensions": {
"bad": {"scripts": ["bad.js"]}
}
}
""",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root))
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_MANIFEST", "manifest.json")
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_SCRIPT_URLS", "/extensions/env.js")
monkeypatch.delenv("HERMES_WEBUI_EXTENSION_STYLESHEET_URLS", raising=False)
from api.extensions import get_extension_config
assert get_extension_config() == {
"enabled": True,
"script_urls": ["/extensions/env.js"],
"stylesheet_urls": [],
}
def test_extension_env_only_duplicate_urls_preserve_legacy_behavior(tmp_path, monkeypatch):
root = tmp_path / "extensions"
root.mkdir()
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root))
monkeypatch.delenv("HERMES_WEBUI_EXTENSION_MANIFEST", raising=False)
monkeypatch.setenv(
"HERMES_WEBUI_EXTENSION_SCRIPT_URLS",
"/extensions/app.js, /extensions/app.js",
)
monkeypatch.delenv("HERMES_WEBUI_EXTENSION_STYLESHEET_URLS", raising=False)
from api.extensions import get_extension_config
assert get_extension_config() == {
"enabled": True,
"script_urls": ["/extensions/app.js", "/extensions/app.js"],
"stylesheet_urls": [],
}
def test_extension_manifest_accepts_top_level_extension_array(tmp_path, monkeypatch):
root = tmp_path / "extensions"
root.mkdir()
(root / "extensions.json").write_text(
"""
[
{"id": "a", "scripts": ["a/a.js"], "stylesheets": ["a/a.css"]},
{"id": "b", "scripts": ["b/b.js"]}
]
""",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root))
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_MANIFEST", "extensions.json")
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": True,
"script_urls": ["/extensions/a/a.js", "/extensions/b/b.js"],
"stylesheet_urls": ["/extensions/a/a.css"],
"extensions": [
{"id": "a", "name": "a", "storage_owned": False, "settings_schema": []},
{"id": "b", "name": "b", "storage_owned": False, "settings_schema": []},
],
}
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