mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 23:47:41 +08:00
fix(security): contain plugin zip extraction (#6051)
This commit is contained in:
+108
-38
@@ -5,13 +5,14 @@ import io
|
|||||||
import json
|
import json
|
||||||
import shutil
|
import shutil
|
||||||
import site
|
import site
|
||||||
|
import stat
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import traceback
|
import traceback
|
||||||
import zipfile
|
import zipfile
|
||||||
from pathlib import Path
|
from pathlib import Path, PurePosixPath, PureWindowsPath
|
||||||
from typing import Dict, List, Optional, Tuple, Set, Callable, Awaitable
|
from typing import Dict, List, Optional, Tuple, Set, Callable, Awaitable
|
||||||
from urllib.parse import parse_qs, quote, unquote, urlsplit
|
from urllib.parse import parse_qs, quote, unquote, urlsplit
|
||||||
|
|
||||||
@@ -1723,6 +1724,95 @@ class PluginHelper(metaclass=WeakSingleton):
|
|||||||
self.refresh_persistent_plugin_backup(pid)
|
self.refresh_persistent_plugin_backup(pid)
|
||||||
return True, ""
|
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]:
|
def __install_from_release(self, pid: str, user_repo: str, release_tag: str) -> Tuple[bool, str]:
|
||||||
"""
|
"""
|
||||||
通过 GitHub Release 资产文件安装插件。
|
通过 GitHub Release 资产文件安装插件。
|
||||||
@@ -1766,29 +1856,18 @@ class PluginHelper(metaclass=WeakSingleton):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
with zipfile.ZipFile(io.BytesIO(res.content)) as zf:
|
with zipfile.ZipFile(io.BytesIO(res.content)) as zf:
|
||||||
namelist = zf.namelist()
|
infos = zf.infolist()
|
||||||
if not namelist:
|
if not infos:
|
||||||
return False, "压缩包内容为空"
|
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()
|
dest_base = Path(settings.ROOT_PATH) / "app" / "plugins" / pid.lower()
|
||||||
|
targets = self.__iter_release_zip_targets(zf, dest_base)
|
||||||
wrote_any = False
|
wrote_any = False
|
||||||
for name in namelist:
|
for info, dest_path, is_dir in targets:
|
||||||
rel_path = name[len(base_prefix):]
|
if is_dir:
|
||||||
if not rel_path:
|
dest_path.mkdir(parents=True, exist_ok=True)
|
||||||
continue
|
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)
|
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())
|
dst.write(src.read())
|
||||||
wrote_any = True
|
wrote_any = True
|
||||||
if not wrote_any:
|
if not wrote_any:
|
||||||
@@ -2783,28 +2862,19 @@ class PluginHelper(metaclass=WeakSingleton):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
with zipfile.ZipFile(io.BytesIO(res.content)) as zf:
|
with zipfile.ZipFile(io.BytesIO(res.content)) as zf:
|
||||||
namelist = zf.namelist()
|
infos = zf.infolist()
|
||||||
if not namelist:
|
if not infos:
|
||||||
return False, "压缩包内容为空"
|
return False, "压缩包内容为空"
|
||||||
names_with_slash = [n for n in namelist if '/' in n]
|
dest_base = Path(settings.ROOT_PATH) / "app" / "plugins" / pid.lower()
|
||||||
base_prefix = ''
|
targets = self.__iter_release_zip_targets(zf, dest_base)
|
||||||
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()
|
|
||||||
wrote_any = False
|
wrote_any = False
|
||||||
for name in namelist:
|
for info, dest_path, is_dir in targets:
|
||||||
rel_path = name[len(base_prefix):]
|
async_dest_path = AsyncPath(dest_path)
|
||||||
if not rel_path:
|
if is_dir:
|
||||||
|
await async_dest_path.mkdir(parents=True, exist_ok=True)
|
||||||
continue
|
continue
|
||||||
if rel_path.endswith('/'):
|
await async_dest_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
await (dest_base / rel_path.rstrip('/')).mkdir(parents=True, exist_ok=True)
|
with zf.open(info, 'r') as src:
|
||||||
continue
|
|
||||||
dest_path = dest_base / rel_path
|
|
||||||
await dest_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
with zf.open(name, 'r') as src:
|
|
||||||
data = src.read()
|
data = src.read()
|
||||||
async with aiofiles.open(dest_path, 'wb') as dst:
|
async with aiofiles.open(dest_path, 'wb') as dst:
|
||||||
await dst.write(data)
|
await dst.write(data)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import io
|
import io
|
||||||
|
import stat
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import threading
|
import threading
|
||||||
@@ -60,6 +61,40 @@ def _build_zip(entries: dict[str, bytes]) -> bytes:
|
|||||||
return buffer.getvalue()
|
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,
|
def _patch_sync_remote_install(helper, monkeypatch, meta: dict,
|
||||||
release_result: tuple[bool, str],
|
release_result: tuple[bool, str],
|
||||||
filelist_result: tuple[bool, str] = (True, "")):
|
filelist_result: tuple[bool, str] = (True, "")):
|
||||||
@@ -1752,6 +1787,43 @@ class TestPluginHelper:
|
|||||||
assert not success
|
assert not success
|
||||||
assert "下载资产失败:502" == message
|
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):
|
def test_install_from_release_extracts_zip_with_top_level_directory(self, monkeypatch, tmp_path):
|
||||||
"""
|
"""
|
||||||
release zip 带顶层插件目录时剥离该层后写入运行目录。
|
release zip 带顶层插件目录时剥离该层后写入运行目录。
|
||||||
@@ -2287,6 +2359,49 @@ class TestPluginHelper:
|
|||||||
assert not success
|
assert not success
|
||||||
assert "下载资产失败:502" == message
|
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):
|
def test_async_install_from_release_extracts_zip_with_top_level_directory(self, monkeypatch, tmp_path):
|
||||||
"""
|
"""
|
||||||
异步 release zip 带顶层插件目录时剥离该层后写入运行目录。
|
异步 release zip 带顶层插件目录时剥离该层后写入运行目录。
|
||||||
|
|||||||
Reference in New Issue
Block a user