fix(security): contain plugin zip extraction (#6051)

This commit is contained in:
InfinityPacer
2026-07-05 09:26:09 +08:00
committed by GitHub
parent 064e6535d5
commit ab5995a609
2 changed files with 223 additions and 38 deletions
+108 -38
View File
@@ -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)
+115
View File
@@ -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 带顶层插件目录时剥离该层后写入运行目录。