mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-07-07 12:29:06 +08:00
fix(security): contain plugin zip extraction (#6051)
This commit is contained in:
@@ -5,13 +5,14 @@ import io
|
||||
import json
|
||||
import shutil
|
||||
import site
|
||||
import stat
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from pathlib import Path, PurePosixPath, PureWindowsPath
|
||||
from typing import Dict, List, Optional, Tuple, Set, Callable, Awaitable
|
||||
from urllib.parse import parse_qs, quote, unquote, urlsplit
|
||||
|
||||
@@ -1723,6 +1724,95 @@ class PluginHelper(metaclass=WeakSingleton):
|
||||
self.refresh_persistent_plugin_backup(pid)
|
||||
return True, ""
|
||||
|
||||
@staticmethod
|
||||
def __validate_release_zip_name(name: str) -> None:
|
||||
"""
|
||||
校验 release zip 成员名在 POSIX 与 Windows 语义下都只能表示相对路径。
|
||||
"""
|
||||
if not name:
|
||||
raise ValueError("非法 Release 压缩包成员:成员名为空")
|
||||
if "\x00" in name:
|
||||
raise ValueError(f"非法 Release 压缩包成员:{name}")
|
||||
if "\\" in name:
|
||||
raise ValueError(f"非法 Release 压缩包成员:{name}")
|
||||
|
||||
posix_path = PurePosixPath(name)
|
||||
windows_path = PureWindowsPath(name)
|
||||
if (
|
||||
name.startswith("//")
|
||||
or posix_path.is_absolute()
|
||||
or windows_path.is_absolute()
|
||||
or windows_path.drive
|
||||
):
|
||||
raise ValueError(f"非法 Release 压缩包成员:{name}")
|
||||
|
||||
parts = [part for part in posix_path.parts if part not in ("", ".")]
|
||||
if not parts:
|
||||
raise ValueError(f"非法 Release 压缩包成员:{name}")
|
||||
if ".." in parts:
|
||||
raise ValueError(f"非法 Release 压缩包成员:{name}")
|
||||
|
||||
@staticmethod
|
||||
def __validate_release_zip_type(info: zipfile.ZipInfo) -> None:
|
||||
"""
|
||||
release zip 只接受普通文件和目录,避免归档内的符号链接或设备文件影响安装边界。
|
||||
"""
|
||||
mode = info.external_attr >> 16
|
||||
file_type = stat.S_IFMT(mode)
|
||||
if not file_type:
|
||||
return
|
||||
if stat.S_ISREG(mode) or stat.S_ISDIR(mode):
|
||||
return
|
||||
raise ValueError(f"非法 Release 压缩包成员:{info.filename}")
|
||||
|
||||
@staticmethod
|
||||
def __get_release_zip_base_prefix(infos: List[zipfile.ZipInfo]) -> str:
|
||||
"""
|
||||
识别 release zip 的单一顶层目录,用于保持插件包根目录剥离行为。
|
||||
"""
|
||||
names = [info.filename for info in infos]
|
||||
names_with_slash = [name for name in names if "/" in name]
|
||||
if names_with_slash and len(names_with_slash) == len(names):
|
||||
first_seg = names_with_slash[0].split("/", 1)[0]
|
||||
if first_seg and all(name.startswith(first_seg + "/") for name in names):
|
||||
return first_seg + "/"
|
||||
return ""
|
||||
|
||||
@classmethod
|
||||
def __iter_release_zip_targets(
|
||||
cls, zf: zipfile.ZipFile, dest_base: Path
|
||||
) -> List[Tuple[zipfile.ZipInfo, Path, bool]]:
|
||||
"""
|
||||
将 release zip 成员解析为安装目标路径,并保证目标路径不会逃逸插件目录。
|
||||
"""
|
||||
infos = zf.infolist()
|
||||
for info in infos:
|
||||
cls.__validate_release_zip_type(info)
|
||||
cls.__validate_release_zip_name(info.filename)
|
||||
|
||||
base_prefix = cls.__get_release_zip_base_prefix(infos)
|
||||
dest_root = dest_base.resolve()
|
||||
targets = []
|
||||
for info in infos:
|
||||
raw_name = info.filename
|
||||
rel_name = raw_name[len(base_prefix):] if base_prefix else raw_name
|
||||
if not rel_name:
|
||||
if base_prefix and raw_name == base_prefix:
|
||||
continue
|
||||
raise ValueError(f"非法 Release 压缩包成员:{raw_name}")
|
||||
|
||||
cls.__validate_release_zip_name(rel_name)
|
||||
rel_parts = [part for part in PurePosixPath(rel_name).parts if part not in ("", ".")]
|
||||
if not rel_parts:
|
||||
raise ValueError(f"非法 Release 压缩包成员:{raw_name}")
|
||||
dest_path = (dest_root / Path(*rel_parts)).resolve()
|
||||
try:
|
||||
dest_path.relative_to(dest_root)
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"非法 Release 压缩包成员:{raw_name}") from exc
|
||||
targets.append((info, dest_path, info.is_dir()))
|
||||
return targets
|
||||
|
||||
def __install_from_release(self, pid: str, user_repo: str, release_tag: str) -> Tuple[bool, str]:
|
||||
"""
|
||||
通过 GitHub Release 资产文件安装插件。
|
||||
@@ -1766,29 +1856,18 @@ class PluginHelper(metaclass=WeakSingleton):
|
||||
|
||||
try:
|
||||
with zipfile.ZipFile(io.BytesIO(res.content)) as zf:
|
||||
namelist = zf.namelist()
|
||||
if not namelist:
|
||||
infos = zf.infolist()
|
||||
if not infos:
|
||||
return False, "压缩包内容为空"
|
||||
# 若所有条目均在同一顶层目录下(如 pid/),则剥离这一层,避免出现双层目录
|
||||
names_with_slash = [n for n in namelist if '/' in n]
|
||||
base_prefix = ''
|
||||
if names_with_slash and len(names_with_slash) == len(namelist):
|
||||
first_seg = names_with_slash[0].split('/')[0]
|
||||
if all(n.startswith(first_seg + '/') for n in namelist):
|
||||
base_prefix = first_seg + '/'
|
||||
|
||||
dest_base = Path(settings.ROOT_PATH) / "app" / "plugins" / pid.lower()
|
||||
targets = self.__iter_release_zip_targets(zf, dest_base)
|
||||
wrote_any = False
|
||||
for name in namelist:
|
||||
rel_path = name[len(base_prefix):]
|
||||
if not rel_path:
|
||||
for info, dest_path, is_dir in targets:
|
||||
if is_dir:
|
||||
dest_path.mkdir(parents=True, exist_ok=True)
|
||||
continue
|
||||
if rel_path.endswith('/'):
|
||||
(dest_base / rel_path.rstrip('/')).mkdir(parents=True, exist_ok=True)
|
||||
continue
|
||||
dest_path = dest_base / rel_path
|
||||
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with zf.open(name, 'r') as src, open(dest_path, 'wb') as dst:
|
||||
with zf.open(info, 'r') as src, open(dest_path, 'wb') as dst:
|
||||
dst.write(src.read())
|
||||
wrote_any = True
|
||||
if not wrote_any:
|
||||
@@ -2783,28 +2862,19 @@ class PluginHelper(metaclass=WeakSingleton):
|
||||
|
||||
try:
|
||||
with zipfile.ZipFile(io.BytesIO(res.content)) as zf:
|
||||
namelist = zf.namelist()
|
||||
if not namelist:
|
||||
infos = zf.infolist()
|
||||
if not infos:
|
||||
return False, "压缩包内容为空"
|
||||
names_with_slash = [n for n in namelist if '/' in n]
|
||||
base_prefix = ''
|
||||
if names_with_slash and len(names_with_slash) == len(namelist):
|
||||
first_seg = names_with_slash[0].split('/')[0]
|
||||
if all(n.startswith(first_seg + '/') for n in namelist):
|
||||
base_prefix = first_seg + '/'
|
||||
|
||||
dest_base = AsyncPath(settings.ROOT_PATH) / "app" / "plugins" / pid.lower()
|
||||
dest_base = Path(settings.ROOT_PATH) / "app" / "plugins" / pid.lower()
|
||||
targets = self.__iter_release_zip_targets(zf, dest_base)
|
||||
wrote_any = False
|
||||
for name in namelist:
|
||||
rel_path = name[len(base_prefix):]
|
||||
if not rel_path:
|
||||
for info, dest_path, is_dir in targets:
|
||||
async_dest_path = AsyncPath(dest_path)
|
||||
if is_dir:
|
||||
await async_dest_path.mkdir(parents=True, exist_ok=True)
|
||||
continue
|
||||
if rel_path.endswith('/'):
|
||||
await (dest_base / rel_path.rstrip('/')).mkdir(parents=True, exist_ok=True)
|
||||
continue
|
||||
dest_path = dest_base / rel_path
|
||||
await dest_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with zf.open(name, 'r') as src:
|
||||
await async_dest_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with zf.open(info, 'r') as src:
|
||||
data = src.read()
|
||||
async with aiofiles.open(dest_path, 'wb') as dst:
|
||||
await dst.write(data)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import asyncio
|
||||
import io
|
||||
import stat
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
@@ -60,6 +61,40 @@ def _build_zip(entries: dict[str, bytes]) -> bytes:
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def _build_release_zip_member(name: str, *, symlink: bool = False) -> bytes:
|
||||
"""构造单成员 release zip,用于覆盖成员路径与文件类型校验。"""
|
||||
buffer = io.BytesIO()
|
||||
with zipfile.ZipFile(buffer, "w") as zf:
|
||||
if symlink:
|
||||
info = zipfile.ZipInfo(name)
|
||||
info.create_system = 3
|
||||
info.external_attr = (stat.S_IFLNK | 0o777) << 16
|
||||
zf.writestr(info, b"target")
|
||||
else:
|
||||
zf.writestr(name, b"evil")
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def _patch_release_install_settings(monkeypatch, tmp_path: Path) -> None:
|
||||
"""隔离 release 安装根目录,并阻止测试误触真实根路径。"""
|
||||
monkeypatch.setattr("app.helper.plugin.settings", SimpleNamespace(
|
||||
ROOT_PATH=tmp_path,
|
||||
REPO_GITHUB_HEADERS=lambda repo=None: {},
|
||||
))
|
||||
|
||||
original_mkdir = Path.mkdir
|
||||
safe_root = tmp_path.resolve()
|
||||
|
||||
def guarded_mkdir(path: Path, *args, **kwargs):
|
||||
try:
|
||||
path.resolve().relative_to(safe_root)
|
||||
except ValueError as exc:
|
||||
raise AssertionError(f"unsafe mkdir attempted: {path}") from exc
|
||||
return original_mkdir(path, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(Path, "mkdir", guarded_mkdir)
|
||||
|
||||
|
||||
def _patch_sync_remote_install(helper, monkeypatch, meta: dict,
|
||||
release_result: tuple[bool, str],
|
||||
filelist_result: tuple[bool, str] = (True, "")):
|
||||
@@ -1752,6 +1787,43 @@ class TestPluginHelper:
|
||||
assert not success
|
||||
assert "下载资产失败:502" == message
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"member_name, symlink",
|
||||
[
|
||||
("../evil.py", False),
|
||||
("/tmp/evil.py", False),
|
||||
("..\\evil.py", False),
|
||||
("C:/evil.py", False),
|
||||
("//server/share/evil.py", False),
|
||||
("demoplugin/link.py", True),
|
||||
],
|
||||
)
|
||||
def test_install_from_release_rejects_unsafe_zip_member(self, monkeypatch, tmp_path, member_name, symlink):
|
||||
"""
|
||||
release zip 成员必须限制在插件运行目录内,且不能是符号链接或特殊文件。
|
||||
"""
|
||||
try:
|
||||
from app.helper.plugin import PluginHelper
|
||||
except ModuleNotFoundError as exc:
|
||||
pytest.skip(f"missing dependency: {exc}")
|
||||
|
||||
helper = PluginHelper()
|
||||
responses = iter([
|
||||
_FakeResponse(200, {"assets": [{"name": "demoplugin_v1.2.3.zip", "id": 42}]}),
|
||||
_FakeContentResponse(200, _build_release_zip_member(member_name, symlink=symlink)),
|
||||
])
|
||||
_patch_release_install_settings(monkeypatch, tmp_path)
|
||||
monkeypatch.setattr(helper, "_PluginHelper__request_with_fallback", lambda *_args, **_kwargs: next(responses))
|
||||
|
||||
success, message = helper._PluginHelper__install_from_release(PLUGIN_ID, "demo/repo", "DemoPlugin_v1.2.3")
|
||||
|
||||
assert not success
|
||||
assert "非法 Release 压缩包成员" in message
|
||||
assert not (tmp_path / "app" / "plugins" / "evil.py").exists()
|
||||
assert not (tmp_path / "app" / "plugins" / "demoplugin" / "..\\evil.py").exists()
|
||||
assert not (tmp_path / "app" / "plugins" / "demoplugin" / "C:").exists()
|
||||
assert not (tmp_path / "app" / "plugins" / "demoplugin" / "link.py").exists()
|
||||
|
||||
def test_install_from_release_extracts_zip_with_top_level_directory(self, monkeypatch, tmp_path):
|
||||
"""
|
||||
release zip 带顶层插件目录时剥离该层后写入运行目录。
|
||||
@@ -2287,6 +2359,49 @@ class TestPluginHelper:
|
||||
assert not success
|
||||
assert "下载资产失败:502" == message
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"member_name, symlink",
|
||||
[
|
||||
("../evil.py", False),
|
||||
("/tmp/evil.py", False),
|
||||
("..\\evil.py", False),
|
||||
("C:/evil.py", False),
|
||||
("//server/share/evil.py", False),
|
||||
("demoplugin/link.py", True),
|
||||
],
|
||||
)
|
||||
def test_async_install_from_release_rejects_unsafe_zip_member(self, monkeypatch, tmp_path, member_name, symlink):
|
||||
"""
|
||||
异步 release zip 成员使用同步路径相同的边界与文件类型规则。
|
||||
"""
|
||||
try:
|
||||
from app.helper.plugin import PluginHelper
|
||||
except ModuleNotFoundError as exc:
|
||||
pytest.skip(f"missing dependency: {exc}")
|
||||
|
||||
helper = PluginHelper()
|
||||
responses = iter([
|
||||
_FakeResponse(200, {"assets": [{"name": "demoplugin_v1.2.3.zip", "id": 42}]}),
|
||||
_FakeContentResponse(200, _build_release_zip_member(member_name, symlink=symlink)),
|
||||
])
|
||||
|
||||
async def fake_request(*_args, **_kwargs):
|
||||
return next(responses)
|
||||
|
||||
_patch_release_install_settings(monkeypatch, tmp_path)
|
||||
monkeypatch.setattr(helper, "_PluginHelper__async_request_with_fallback", fake_request)
|
||||
|
||||
success, message = asyncio.run(
|
||||
helper._PluginHelper__async_install_from_release(PLUGIN_ID, "demo/repo", "DemoPlugin_v1.2.3")
|
||||
)
|
||||
|
||||
assert not success
|
||||
assert "非法 Release 压缩包成员" in message
|
||||
assert not (tmp_path / "app" / "plugins" / "evil.py").exists()
|
||||
assert not (tmp_path / "app" / "plugins" / "demoplugin" / "..\\evil.py").exists()
|
||||
assert not (tmp_path / "app" / "plugins" / "demoplugin" / "C:").exists()
|
||||
assert not (tmp_path / "app" / "plugins" / "demoplugin" / "link.py").exists()
|
||||
|
||||
def test_async_install_from_release_extracts_zip_with_top_level_directory(self, monkeypatch, tmp_path):
|
||||
"""
|
||||
异步 release zip 带顶层插件目录时剥离该层后写入运行目录。
|
||||
|
||||
Reference in New Issue
Block a user