Files
openhop_repeater/tests/test_plugin_runtime_hardening.py

359 lines
14 KiB
Python

"""Regression tests for untrusted archives and plugin process lifetime."""
import json
import os
import shutil
import signal
import subprocess
import sys
import time
import zipfile
from pathlib import Path
import pytest
from repeater.plugins import manifest as manifest_module
from repeater.plugins import runtime as runtime_module
from repeater.plugins import storage as storage_module
from repeater.plugins.manifest import ManifestError, load_manifest_from_wheel, parse_manifest
from repeater.plugins.runtime import PluginRuntime
from repeater.plugins.storage import PluginStorage
def manifest_data(entry="ui/index.html"):
return {
"schema": 1,
"id": "openhop.demo",
"name": "Demo",
"version": "1.0.0",
"ui": {"type": "application", "entry": entry},
}
@pytest.mark.parametrize(
"entry",
[
"index.html",
"venv/index.html",
"venv/bin/python",
".venv/index.html",
"./index.html",
"ui/./index.html",
],
)
def test_ui_requires_dedicated_non_reserved_subtree(entry):
with pytest.raises(ManifestError):
parse_manifest(manifest_data(entry))
@pytest.mark.parametrize("kind", ["directory", "leaf", "config"])
def test_extract_rejects_existing_symlinks(tmp_path, kind):
release = tmp_path / "release"
release.mkdir()
outside = tmp_path / "outside"
outside.mkdir()
victim = outside / "index.html"
victim.write_text("untouched")
wheel = tmp_path / "demo.whl"
with zipfile.ZipFile(wheel, "w") as zf:
zf.writestr("ui/index.html", "changed")
zf.writestr("config.default.json", "changed")
if kind == "directory":
(release / "ui").symlink_to(outside, target_is_directory=True)
elif kind == "leaf":
(release / "ui").mkdir()
(release / "ui/index.html").symlink_to(victim)
else:
(release / "config.default.json").symlink_to(victim)
runtime = PluginRuntime(PluginStorage(tmp_path / "plugins"))
with pytest.raises((ValueError, OSError)):
if kind == "config":
runtime._extract_config_default_file(wheel, release)
else:
runtime._extract_ui_assets(wheel, release, parse_manifest(manifest_data()))
assert victim.read_text() == "untouched"
def test_ui_matches_path_components_not_substrings(tmp_path):
wheel = tmp_path / "demo.whl"
release = tmp_path / "release"
release.mkdir()
with zipfile.ZipFile(wheel, "w") as zf:
zf.writestr("pkg/ui/index.html", "good")
zf.writestr("notui/secret.txt", "wrong")
PluginRuntime(PluginStorage(tmp_path))._extract_ui_assets(
wheel, release, parse_manifest(manifest_data())
)
assert (release / "ui/index.html").read_text() == "good"
assert not (release / "ui/secret.txt").exists()
@pytest.mark.parametrize("kind", ["manifest", "config", "metadata", "ui", "total", "count"])
def test_expanded_archive_limits(tmp_path, monkeypatch, kind):
monkeypatch.setattr(manifest_module, "ZIP_METADATA_MAX_BYTES", 128, raising=False)
monkeypatch.setattr(manifest_module, "ZIP_MEMBER_MAX_BYTES", 128, raising=False)
monkeypatch.setattr(manifest_module, "ZIP_TOTAL_MAX_BYTES", 200, raising=False)
monkeypatch.setattr(manifest_module, "ZIP_MAX_MEMBERS", 3, raising=False)
wheel = tmp_path / "upload.whl"
members = {
"manifest": {"openhop-plugin.json": json.dumps(manifest_data()) + " " * 256},
"config": {"config.default.json": " " * 256},
"metadata": {"demo.dist-info/METADATA": " " * 256},
"ui": {"ui/index.html": "x" * 256},
"total": {"ui/a": "x" * 110, "ui/b": "x" * 110},
"count": {f"ui/{i}": "x" for i in range(4)},
}[kind]
with zipfile.ZipFile(wheel, "w", compression=zipfile.ZIP_DEFLATED) as zf:
for name, content in members.items():
zf.writestr(name, content)
release = tmp_path / "release"
release.mkdir()
runtime = PluginRuntime(PluginStorage(tmp_path / "plugins"))
with pytest.raises(ValueError, match="limit|exceeds"):
if kind == "manifest":
load_manifest_from_wheel(wheel)
elif kind == "config":
runtime._extract_config_default_file(wheel, release)
elif kind == "metadata":
runtime._ensure_pip_wheel_filename(wheel)
else:
runtime._extract_ui_assets(wheel, release, parse_manifest(manifest_data()))
def prepared_runtime(tmp_path, **kwargs):
storage = PluginStorage(tmp_path / "plugins")
paths = storage.ensure_plugin_layout("openhop.demo", "1.0.0")
data = manifest_data()
data["runtime"] = {"type": "python", "entrypoint": "demo-cli"}
manifest = parse_manifest(data)
storage.write_manifest(manifest.id, manifest.version, manifest)
storage.write_state(manifest.id, {"version": manifest.version, "enabled": True})
storage.set_current(manifest.id, manifest.version)
return PluginRuntime(storage, **kwargs), paths
def test_failed_rebuild_restores_old_environment_and_retries(tmp_path):
calls = []
fail = True
def run(cmd, **kwargs):
calls.append(cmd)
if "venv" in cmd:
target = Path(cmd[-1])
(target / "bin").mkdir(parents=True)
(target / "bin/python").symlink_to(sys.executable)
(target / "pyvenv.cfg").write_text(
f"version = {sys.version_info.major}.{sys.version_info.minor}.0\n"
)
else:
if fail:
raise RuntimeError("pip failed")
script = Path(cmd[0]).parent / "demo-cli"
script.write_text(f"#!{cmd[0]}\nprint('working')\n")
script.chmod(0o755)
return subprocess.CompletedProcess(cmd, 0, "", "")
runtime, paths = prepared_runtime(tmp_path, run_factory=run)
venv = paths.venv_dir("1.0.0")
(venv / "bin").mkdir(parents=True)
(venv / "bin/python").write_text("old")
(venv / "pyvenv.cfg").write_text("version = 2.7.0\n")
(paths.release_dir("1.0.0") / "demo-1.0.0-py3-none-any.whl").touch()
with pytest.raises(RuntimeError, match="pip failed"):
runtime.ensure_venv_compatible("openhop.demo")
assert (venv / "bin/python").read_text() == "old"
assert (venv / "pyvenv.cfg").read_text() == "version = 2.7.0\n"
fail = False
runtime.ensure_venv_compatible("openhop.demo")
entry = runtime.resolve_entrypoint(paths, "1.0.0", "demo-cli")
assert subprocess.check_output([str(entry)], text=True, timeout=5).strip() == "working"
assert len([cmd for cmd in calls if "pip" in cmd]) == 2
@pytest.mark.parametrize("stage", ["old_renamed", "new_config"])
def test_fresh_runtime_recovers_interrupted_rebuild_snapshot(tmp_path, stage):
snapshot = tmp_path / "snapshot"
calls = []
capturing = True
def run(cmd, **kwargs):
calls.append(cmd)
if "venv" in cmd:
target = Path(cmd[-1])
if capturing and stage == "old_renamed":
shutil.copytree(tmp_path / "plugins", snapshot, symlinks=True)
raise RuntimeError("snapshot captured")
(target / "bin").mkdir(parents=True)
(target / "bin/python").symlink_to(sys.executable)
(target / "pyvenv.cfg").write_text(
f"version = {sys.version_info.major}.{sys.version_info.minor}.0\n"
)
if capturing:
shutil.copytree(tmp_path / "plugins", snapshot, symlinks=True)
raise RuntimeError("snapshot captured")
else:
entry = Path(cmd[0]).parent / "demo-cli"
entry.write_text(f"#!{cmd[0]}\nprint('recovered')\n")
entry.chmod(0o755)
return subprocess.CompletedProcess(cmd, 0, "", "")
runtime, paths = prepared_runtime(tmp_path, run_factory=run)
venv = paths.venv_dir("1.0.0")
(venv / "bin").mkdir(parents=True)
(venv / "bin/python").write_text("old")
(venv / "pyvenv.cfg").write_text("version = 2.7.0\n")
(paths.release_dir("1.0.0") / "demo-1.0.0-py3-none-any.whl").touch()
with pytest.raises(RuntimeError, match="snapshot captured"):
runtime.ensure_venv_compatible("openhop.demo")
capturing = False
calls.clear()
fresh = PluginRuntime(PluginStorage(snapshot), run_factory=run)
fresh.ensure_venv_compatible("openhop.demo")
assert len([cmd for cmd in calls if "pip" in cmd]) == 1
entry = fresh.resolve_entrypoint(fresh.storage.paths_for("openhop.demo"), "1.0.0", "demo-cli")
assert subprocess.check_output([str(entry)], text=True, timeout=5).strip() == "recovered"
calls.clear()
fresh.ensure_venv_compatible("openhop.demo")
assert calls == []
def test_install_subprocesses_have_deadlines(tmp_path):
calls = []
def run(cmd, **kwargs):
calls.append(kwargs)
return subprocess.CompletedProcess(cmd, 0, "", "")
runtime = PluginRuntime(PluginStorage(tmp_path), run_factory=run)
runtime._create_venv(tmp_path / "venv")
(tmp_path / "venv/bin").mkdir(parents=True)
(tmp_path / "venv/bin/python").touch()
runtime._pip_install(tmp_path / "venv", tmp_path / "demo-1.0.0-py3-none-any.whl")
assert all(0 < call.get("timeout", 0) <= 300 for call in calls)
def test_log_tail_reads_only_bounded_suffix(tmp_path, monkeypatch):
monkeypatch.setattr(storage_module, "LOG_TAIL_MAX_BYTES", 128, raising=False)
storage = PluginStorage(tmp_path / "plugins")
paths = storage.ensure_plugin_layout("openhop.demo", "1.0.0")
paths.log_file.write_text("a" * 1024 + "\nlast line\n")
result = storage.tail_log("openhop.demo")
assert result[-1] == "last line"
assert len("\n".join(result).encode()) <= 128
def test_running_plugin_log_is_continuously_bounded(tmp_path, monkeypatch):
monkeypatch.setattr(runtime_module, "LOG_MAX_BYTES", 1024)
runtime, paths = prepared_runtime(tmp_path)
bindir = paths.venv_dir("1.0.0") / "bin"
bindir.mkdir(parents=True)
entry = bindir / "demo-cli"
marker = tmp_path / "ready"
entry.write_text(
f"#!{sys.executable}\nimport os,time\nfrom pathlib import Path\nos.write(1, b'x' * 32768)\nPath({str(marker)!r}).touch()\ntime.sleep(60)\n"
)
entry.chmod(0o755)
try:
runtime.start("openhop.demo")
deadline = time.monotonic() + 5
while not marker.exists() and time.monotonic() < deadline:
time.sleep(0.01)
assert marker.exists()
time.sleep(0.1)
assert paths.log_file.stat().st_size <= 1024
finally:
runtime.stop("openhop.demo")
def test_install_output_is_bounded(tmp_path, monkeypatch):
monkeypatch.setattr(runtime_module, "INSTALL_OUTPUT_MAX_BYTES", 128, raising=False)
runtime = PluginRuntime(PluginStorage(tmp_path))
result = runtime._run(
[sys.executable, "-c", "print('x' * 8192)"],
capture_output=True,
text=True,
check=False,
timeout=5,
)
assert len(result.stdout.encode()) <= 128
@pytest.mark.skipif(sys.platform != "linux", reason="Linux process-group regression")
def test_install_timeout_kills_child_processes(tmp_path):
marker = tmp_path / "child.pid"
code = (
"import subprocess,time; from pathlib import Path; "
f"p=subprocess.Popen([{sys.executable!r}, '-c', 'import time; time.sleep(60)']); "
f"Path({str(marker)!r}).write_text(str(p.pid)); time.sleep(60)"
)
runtime = PluginRuntime(PluginStorage(tmp_path))
pid = None
try:
with pytest.raises(subprocess.TimeoutExpired):
runtime._run(
[sys.executable, "-c", code],
capture_output=True,
text=True,
check=False,
timeout=0.3,
)
assert marker.exists()
pid = int(marker.read_text())
deadline = time.monotonic() + 2
while alive(pid) and time.monotonic() < deadline:
time.sleep(0.01)
assert not alive(pid)
finally:
if marker.exists():
pid = int(marker.read_text())
if pid and alive(pid):
os.kill(pid, signal.SIGKILL)
def alive(pid):
try:
return Path(f"/proc/{pid}/stat").read_text().split()[2] != "Z"
except (FileNotFoundError, ProcessLookupError):
return False
@pytest.mark.skipif(sys.platform != "linux", reason="Linux process-group regression")
@pytest.mark.parametrize("parent_exits", [False, True])
def test_stop_kills_term_ignoring_descendant_even_after_parent_exit(tmp_path, parent_exits):
runtime, paths = prepared_runtime(tmp_path, stop_timeout=0.15)
venv = paths.venv_dir("1.0.0")
(venv / "bin").mkdir(parents=True)
entry = venv / "bin/demo-cli"
marker = tmp_path / "child.pid"
child = "import signal,time; signal.signal(signal.SIGTERM, signal.SIG_IGN); time.sleep(60)"
entry.write_text(
f"#!{sys.executable}\nimport subprocess,time\nfrom pathlib import Path\np = subprocess.Popen([{sys.executable!r}, '-c', {child!r}])\nPath({str(marker)!r}).write_text(str(p.pid))\ntime.sleep({0.1 if parent_exits else 60})\n"
)
entry.chmod(0o755)
pid = None
proc = None
try:
runtime.start("openhop.demo")
proc = runtime._handles["openhop.demo"].process
deadline = time.monotonic() + 5
while not marker.exists() and time.monotonic() < deadline:
time.sleep(0.01)
assert marker.exists()
pid = int(marker.read_text())
time.sleep(0.15)
if parent_exits:
proc.wait(timeout=5)
runtime.stop("openhop.demo")
deadline = time.monotonic() + 2
while alive(pid) and time.monotonic() < deadline:
time.sleep(0.01)
assert not alive(pid)
finally:
if pid and alive(pid):
os.kill(pid, signal.SIGKILL)
if proc and proc.poll() is None:
proc.kill()
proc.wait(timeout=5)
runtime.stop("openhop.demo")