Files
MoviePilot/tests/test_plugin_data_directory_containment.py

213 lines
8.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""插件数据目录解析的包含性回归测试。
插件标识在重置、卸载、彻底清理等路径上直接来自 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)