mirror of
https://github.com/Quad4-Software/MeshChatX.git
synced 2026-08-18 09:49:09 -04:00
163 lines
6 KiB
Python
163 lines
6 KiB
Python
# SPDX-License-Identifier: 0BSD
|
|
|
|
import io
|
|
import json
|
|
import os
|
|
import zipfile
|
|
|
|
import pytest
|
|
|
|
from meshchatx.src.backend.plugin_guard import (
|
|
PluginSecurityError,
|
|
normalize_asset_path,
|
|
safe_extract_zip,
|
|
validate_zip_bytes,
|
|
)
|
|
|
|
|
|
def _make_manager(tmp_path, app=None):
|
|
from meshchatx.src.backend.plugin_manager import PluginManager
|
|
|
|
return PluginManager(str(tmp_path), app=app)
|
|
|
|
|
|
def _write_plugin_dir(root, plugin_id="com.example.secure-plugin"):
|
|
os.makedirs(root, exist_ok=True)
|
|
manifest = {
|
|
"id": plugin_id,
|
|
"version": "1.0.0",
|
|
"apiVersion": 1,
|
|
"name": "Secure Plugin",
|
|
"description": "Security test plugin",
|
|
"frontend": {"entry": "frontend/main.js", "type": "js"},
|
|
"i18n": {"directory": "locales", "defaultLocale": "en"},
|
|
}
|
|
with open(os.path.join(root, "plugin.json"), "w", encoding="utf-8") as handle:
|
|
json.dump(manifest, handle)
|
|
os.makedirs(os.path.join(root, "frontend"), exist_ok=True)
|
|
with open(
|
|
os.path.join(root, "frontend", "main.js"),
|
|
"w",
|
|
encoding="utf-8",
|
|
) as handle:
|
|
handle.write(
|
|
"export async function activate(api) { api.setUi({ type: 'text', value: 'ok' }); }",
|
|
)
|
|
os.makedirs(os.path.join(root, "locales"), exist_ok=True)
|
|
with open(
|
|
os.path.join(root, "locales", "en.json"),
|
|
"w",
|
|
encoding="utf-8",
|
|
) as handle:
|
|
json.dump({"title": "Secure Plugin"}, handle)
|
|
|
|
|
|
def _zip_directory(source_dir):
|
|
buffer = io.BytesIO()
|
|
with zipfile.ZipFile(buffer, "w") as archive:
|
|
for base, _, files in os.walk(source_dir):
|
|
for name in files:
|
|
path = os.path.join(base, name)
|
|
archive.write(path, os.path.relpath(path, source_dir))
|
|
return buffer.getvalue()
|
|
|
|
|
|
class TestPluginGuard:
|
|
def test_normalize_asset_path_rejects_traversal(self):
|
|
with pytest.raises(PluginSecurityError):
|
|
normalize_asset_path("../plugin.json")
|
|
with pytest.raises(PluginSecurityError):
|
|
normalize_asset_path("/etc/passwd")
|
|
with pytest.raises(PluginSecurityError):
|
|
normalize_asset_path("C:/Windows/x")
|
|
with pytest.raises(PluginSecurityError):
|
|
normalize_asset_path("foo/\x00/bar")
|
|
|
|
def test_zip_entry_rejects_windows_drive_and_nul(self):
|
|
from meshchatx.src.backend.plugin_guard import _zip_entry_is_safe
|
|
|
|
assert _zip_entry_is_safe("ok/a.wasm") is True
|
|
assert _zip_entry_is_safe("C:/Windows/x") is False
|
|
assert _zip_entry_is_safe("a/\x00/b") is False
|
|
|
|
def test_validate_zip_bytes_rejects_empty_and_random_payload(self):
|
|
with pytest.raises(PluginSecurityError):
|
|
validate_zip_bytes(b"")
|
|
with pytest.raises(PluginSecurityError):
|
|
validate_zip_bytes(os.urandom(64))
|
|
|
|
def test_safe_extract_zip_rejects_zip_slip(self, tmp_path):
|
|
zip_path = tmp_path / "evil.zip"
|
|
extract_dir = tmp_path / "extract"
|
|
extract_dir.mkdir()
|
|
with zipfile.ZipFile(zip_path, "w") as archive:
|
|
archive.writestr("../escape.txt", "bad")
|
|
with pytest.raises(PluginSecurityError):
|
|
safe_extract_zip(str(zip_path), str(extract_dir))
|
|
|
|
def test_install_zip_with_traversal_is_rejected(self, tmp_path):
|
|
manager = _make_manager(tmp_path)
|
|
zip_path = tmp_path / "evil.zip"
|
|
with zipfile.ZipFile(zip_path, "w") as archive:
|
|
archive.writestr("../escape.txt", "bad")
|
|
with pytest.raises(PluginSecurityError):
|
|
manager.install_from_zip_bytes(zip_path.read_bytes())
|
|
|
|
def test_install_valid_zip_roundtrip(self, tmp_path):
|
|
manager = _make_manager(tmp_path)
|
|
source = tmp_path / "source"
|
|
_write_plugin_dir(str(source))
|
|
plugin = manager.install_from_zip_bytes(_zip_directory(str(source)))
|
|
assert plugin["id"] == "com.example.secure-plugin"
|
|
assert manager.locale_path(plugin["id"], "en").endswith("locales/en.json")
|
|
|
|
def test_asset_path_blocks_traversal(self, tmp_path):
|
|
manager = _make_manager(tmp_path)
|
|
source = tmp_path / "source"
|
|
_write_plugin_dir(str(source))
|
|
manager.install_from_directory(str(source))
|
|
with pytest.raises(PluginSecurityError):
|
|
manager.asset_path("com.example.secure-plugin", "../plugin.json")
|
|
|
|
def test_report_failure_auto_disables_after_budget(self, tmp_path):
|
|
manager = _make_manager(tmp_path)
|
|
source = tmp_path / "source"
|
|
_write_plugin_dir(str(source))
|
|
manager.install_from_directory(str(source))
|
|
plugin_id = "com.example.secure-plugin"
|
|
manager.enable(plugin_id)
|
|
for _ in range(5):
|
|
manager.report_failure(plugin_id, "worker crash", "frontend")
|
|
plugin = manager.get_plugin(plugin_id)
|
|
assert plugin is not None
|
|
assert plugin["enabled"] is False
|
|
assert plugin["auto_disabled_reason"]
|
|
|
|
def test_enable_rejects_missing_frontend_entry(self, tmp_path):
|
|
manager = _make_manager(tmp_path)
|
|
source = tmp_path / "broken"
|
|
os.makedirs(source, exist_ok=True)
|
|
manifest = {
|
|
"id": "com.example.broken",
|
|
"version": "1.0.0",
|
|
"apiVersion": 1,
|
|
"frontend": {"entry": "frontend/missing.js", "type": "js"},
|
|
}
|
|
with open(os.path.join(source, "plugin.json"), "w", encoding="utf-8") as handle:
|
|
json.dump(manifest, handle)
|
|
manager.install_from_directory(str(source))
|
|
with pytest.raises(FileNotFoundError):
|
|
manager.enable("com.example.broken")
|
|
|
|
@pytest.mark.parametrize(
|
|
"payload",
|
|
[
|
|
b"\x91\x0c\xb0\xd9\xe8>\x1eZ \x00\x94\xbe\x9aJ\xf8\xed(u\xa6\xbf\xa9\x05\x8b\x80\xbe\x07\xf7>\x06b\xed",
|
|
b"not-a-zip",
|
|
b"\x00\x01\x02",
|
|
],
|
|
)
|
|
def test_fuzz_random_install_payloads_are_rejected(self, tmp_path, payload):
|
|
manager = _make_manager(tmp_path)
|
|
with pytest.raises(Exception):
|
|
manager.install_from_zip_bytes(payload)
|