mirror of
https://hubproxy.babadafafafafa.cn/https://github.com/jxxghp/MoviePilot.git
synced 2026-09-20 08:03:34 +08:00
213 lines
8.0 KiB
Python
213 lines
8.0 KiB
Python
"""插件数据目录解析的包含性回归测试。
|
||
|
||
插件标识在重置、卸载、彻底清理等路径上直接来自 HTTP 路径段,而插件数据根是配置根下
|
||
的一层子目录。把标识就地拼进路径再交给文件系统,一个 ``..`` 就足以让删除动作抬到配置
|
||
根上。本文件按「编码形式」逐个钉住这条边界:只挡住字面量 ``..`` 是不够的,URL 编码、
|
||
反斜杠、绝对路径与软链都能绕过字符过滤,可靠的判据只有「解析出来的真实路径确实落在
|
||
允许的根目录之内」。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
|
||
import app.db.plugin.registry as registry_module
|
||
import app.runtime.extensions.plugin.datadir as datadir_module
|
||
from app.db.plugin.locator import DATABASE_FILENAME, sqlite_database_path
|
||
from app.runtime.extensions.plugin.datadir import (
|
||
remove_plugin_data_directory,
|
||
resolve_plugin_data_directory,
|
||
)
|
||
|
||
# 用户可控标识到达文件系统前可能呈现的形态。服务端拿到的是 ASGI 解码之后的路径段,
|
||
# 因而 ``%2e%2e`` 与 ``..``、``..%2f`` 与 ``../`` 在处理函数里完全等价——只在路由层面
|
||
# 过滤字面量 ``..`` 拦不住任何一种。
|
||
TRAVERSAL_IDS = (
|
||
"..",
|
||
"../",
|
||
"..\\",
|
||
"./..",
|
||
".././..",
|
||
"a/../..",
|
||
"/etc",
|
||
"C:\\Windows",
|
||
"",
|
||
".",
|
||
)
|
||
|
||
|
||
@pytest.fixture(name="config_root")
|
||
def fixture_config_root(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||
"""搭出「配置根 / 插件数据根 / 同级受保护内容」这一真实布局。
|
||
|
||
插件数据根在生产里就是 ``CONFIG_PATH/plugins``,逃出一层即落在配置根上。受保护
|
||
目录、宿主库文件与哨兵内容用来证明越界删除确实发生过,而不是只看函数返回值。
|
||
"""
|
||
config_root = tmp_path / "config"
|
||
plugin_root = config_root / "plugins"
|
||
(plugin_root / "DemoPlugin").mkdir(parents=True)
|
||
(plugin_root / "DemoPlugin" / "cache.bin").write_text("x", encoding="utf-8")
|
||
protected = config_root / "protected"
|
||
protected.mkdir()
|
||
(protected / "keep.txt").write_text("must survive", encoding="utf-8")
|
||
(config_root / "user.db").write_text("must survive", encoding="utf-8")
|
||
(config_root / DATABASE_FILENAME).write_text("must survive", encoding="utf-8")
|
||
monkeypatch.setattr(
|
||
datadir_module,
|
||
"get_runtime_setting",
|
||
lambda key, default=None: plugin_root if key == "PLUGIN_DATA_PATH" else default,
|
||
)
|
||
return config_root
|
||
|
||
|
||
def _sentinels_intact(config_root: Path) -> bool:
|
||
"""插件数据根之外的内容是否原封不动。"""
|
||
return (
|
||
(config_root / "protected" / "keep.txt").is_file()
|
||
and (config_root / "user.db").is_file()
|
||
and (config_root / DATABASE_FILENAME).is_file()
|
||
)
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# 目录解析
|
||
# --------------------------------------------------------------------------- #
|
||
|
||
|
||
@pytest.mark.parametrize("plugin_id", TRAVERSAL_IDS)
|
||
def test_resolution_rejects_every_encoding_of_an_escaping_id(plugin_id: str) -> None:
|
||
"""越界标识的每一种写法都要在解析阶段被拒。"""
|
||
with pytest.raises(ValueError):
|
||
resolve_plugin_data_directory(plugin_id)
|
||
|
||
|
||
def test_resolution_keeps_legacy_ids_with_separators_in_their_name(
|
||
config_root: Path,
|
||
) -> None:
|
||
"""判据是「必须是一个路径段」而不是字符白名单,带下划线、连字符的旧插件照常可用。
|
||
|
||
插件标识历史上就是 Python 类名,``My_Plugin`` 一类写法一直存在;把它们一并拒掉
|
||
是纯粹的功能回退,而它们本来就逃不出数据根。
|
||
"""
|
||
for plugin_id in ("My_Plugin", "My-Plugin", "plugin.v2", "中文插件"):
|
||
assert resolve_plugin_data_directory(plugin_id) == (
|
||
config_root / "plugins" / plugin_id
|
||
)
|
||
|
||
|
||
def test_percent_encoded_looking_names_are_ordinary_directory_names(
|
||
config_root: Path,
|
||
) -> None:
|
||
"""``%2e%2e`` 这类字面量只是古怪的目录名,不该被当成越界而拒绝。
|
||
|
||
百分号编码是传输层的事:服务端拿到的路径段已经解码过,真正危险的是解码结果。
|
||
把未解码的形态也一并拒掉属于在错误的层次上猜测,反而会挡住合法目录名。
|
||
"""
|
||
for plugin_id in ("%2e%2e", "..%2f", "%2f"):
|
||
assert resolve_plugin_data_directory(plugin_id) == (
|
||
config_root / "plugins" / plugin_id
|
||
)
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# 目录删除
|
||
# --------------------------------------------------------------------------- #
|
||
|
||
|
||
@pytest.mark.parametrize("plugin_id", TRAVERSAL_IDS)
|
||
def test_directory_removal_rejects_escaping_ids_on_its_own(
|
||
config_root: Path,
|
||
plugin_id: str,
|
||
) -> None:
|
||
"""删除函数自身即拒绝越界标识,不指望调用方先做过校验。
|
||
|
||
目录删除是不可逆的终点:它必须自带包含性判断,将来新增调用方忘了校验也不至于把
|
||
配置目录整个删掉。
|
||
"""
|
||
with pytest.raises(ValueError):
|
||
remove_plugin_data_directory(plugin_id)
|
||
|
||
assert _sentinels_intact(config_root)
|
||
|
||
|
||
def test_directory_removal_deletes_only_the_matching_plugin_directory(
|
||
config_root: Path,
|
||
) -> None:
|
||
"""合法标识照常删除,且只删它自己那一层。"""
|
||
assert remove_plugin_data_directory("DemoPlugin") is True
|
||
|
||
assert not (config_root / "plugins" / "DemoPlugin").exists()
|
||
assert (config_root / "plugins").is_dir()
|
||
assert _sentinels_intact(config_root)
|
||
|
||
|
||
def test_directory_removal_reports_a_missing_directory_without_deleting(
|
||
config_root: Path,
|
||
) -> None:
|
||
"""目录本就不存在时如实回报未删除,而不是谎报清理过它。"""
|
||
assert remove_plugin_data_directory("NeverExisted") is False
|
||
|
||
|
||
@pytest.mark.skipif(
|
||
sys.platform.startswith("win"),
|
||
reason="Windows 下建软链需要额外权限,逃逸语义由 POSIX 用例覆盖",
|
||
)
|
||
def test_directory_removal_refuses_to_follow_a_symlink_out_of_the_root(
|
||
config_root: Path,
|
||
) -> None:
|
||
"""数据根下的软链指向根外时拒绝删除,而不是顺着它把目标删掉。
|
||
|
||
``resolve()`` 会跟随软链,因此包含性判断必须建立在解析之后的真实路径上;只比较
|
||
拼接出来的字面路径,一条 ``plugins/Evil -> /config/protected`` 的软链就能骗过它。
|
||
"""
|
||
(config_root / "plugins" / "Evil").symlink_to(
|
||
config_root / "protected", target_is_directory=True
|
||
)
|
||
|
||
with pytest.raises(ValueError):
|
||
remove_plugin_data_directory("Evil")
|
||
|
||
assert _sentinels_intact(config_root)
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# 自有数据库文件定位与销毁
|
||
# --------------------------------------------------------------------------- #
|
||
|
||
|
||
@pytest.mark.parametrize("plugin_id", TRAVERSAL_IDS)
|
||
def test_database_path_rejects_escaping_ids(plugin_id: str) -> None:
|
||
"""库文件路径同样按统一入口解析;销毁会 unlink 它,越界即拒。"""
|
||
with pytest.raises(ValueError):
|
||
sqlite_database_path(plugin_id)
|
||
|
||
|
||
def test_database_path_stays_inside_the_plugin_directory(config_root: Path) -> None:
|
||
"""合法标识下库文件仍落在该插件自己的数据目录里。"""
|
||
assert sqlite_database_path("DemoPlugin") == (
|
||
config_root / "plugins" / "DemoPlugin" / DATABASE_FILENAME
|
||
)
|
||
|
||
|
||
def test_destroying_a_database_with_an_escaping_id_unlinks_nothing(
|
||
config_root: Path,
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""销毁入口拿到越界标识时什么都不删,只留一条日志。
|
||
|
||
这条路径在 ``GET /plugin/reset/{plugin_id}`` 上是可达的:重置按 ``force`` 删数据,
|
||
不检查插件是否存在,``..`` 会把库文件路径解析到配置根下的同名文件上。
|
||
"""
|
||
monkeypatch.setattr(
|
||
registry_module,
|
||
"get_runtime_setting",
|
||
lambda key, default=None: "sqlite" if key == "DB_TYPE" else default,
|
||
)
|
||
|
||
registry_module.destroy_database("..")
|
||
|
||
assert _sentinels_intact(config_root)
|