
pytest 缓存目录逃逸防护解析Cache.get/set/mkdir 与 --cache-show 的路径安全边界【免费下载链接】pytestThe pytest framework makes it easy to write small tests, yet scales to support complex functional testing项目地址: https://gitcode.com/GitHub_Trending/py/pytest导读本文围绕 pytest 的Cache缓存 API 展开深入讲解 changelog/14884.bugfix.rst 记录的这次安全修复——Cache.get()、Cache.set()、Cache.mkdir()如何拒绝解析到缓存目录之外的键以及--cache-show如何忽略 glob 匹配到缓存目录外的内容。读完本文你将理解缓存目录的磁盘布局、键名归一化的底层原理并能在插件或测试中写出安全、合规的缓存读写代码。一、事件背景一次针对缓存目录的路径逃逸修复pytest 内置的 cache 插件实现文件为 src/_pytest/cacheprovider.py提供了两个核心能力--lf/--ff重跑上次失败的测试、--nf新文件优先等命令选项面向插件开发者的cachefixture允许在测试会话之间持久化任意 JSON 可序列化的状态。本次修复源于一个安全/健壮性问题缓存的 key 由调用方用户、插件或测试代码以字符串形式传入而 key 会被直接拼接到缓存目录路径之下。如果 key 中包含..路径段或为绝对路径理论上就可能把文件读写到缓存目录之外——例如覆盖项目根目录下的敏感文件或通过--cache-show的 glob 参数窥探缓存目录外的文件内容。该 bugfix 在三个层面同时收紧边界Cache.get()与Cache.set()拒绝解析出缓存目录的键Cache.mkdir()拒绝解析出缓存目录的名字--cache-show忽略 glob 匹配到缓存目录之外的内容。二、先理解缓存目录的磁盘布局在进入实现细节前先明确缓存数据的存放结构。Cache类中定义了两个子目录常量src/_pytest/cacheprovider.py#L98-L102# Sub-directory under cache-dir for directories created by mkdir(). _CACHE_PREFIX_DIRS d # Sub-directory under cache-dir for values created by set(). _CACHE_PREFIX_VALUES vv/存放由cache.set(key, value)写入的 JSON 值文件d/存放由cache.mkdir(name)创建的目录插件可把数据库转储等跨会话文件放进去。缓存根目录默认是项目根目录下的.pytest_cachecacheprovider.py#L539-L542可通过 ini 配置项cache_dir修改若检测到TOX_ENV_DIR环境变量即在 tox 环境下运行默认值会自动变为TOX_ENV_DIR/.pytest_cache避免各 tox 环境互相覆盖缓存。目录创建时还会附带README.md、.gitignore内容为*防止缓存被误提交到版本控制和CACHEDIR.TAGcacheprovider.py#L38-L56。一个典型的缓存键如cache/nodeids、cache/lastfailed会被映射为v/cache/nodeids、v/cache/lastfailed这样的文件路径。三、问题根源joinpath 对..和绝对路径的“宽容”为什么需要专门修复核心在于pathlib.PurePath.joinpath()的语义源码注释明确指出了这一点见 cacheprovider.py#L164-L176joinpath()lets an absolute or drive-qualifiednamereplacebaseoutright, and keeps..segments verbatim.即存在两个漏洞面绝对路径替换base.joinpath(/absolute/escaped)中绝对路径会整体替换掉base文件读写直接跳到缓存目录之外..段保留base.joinpath(plugin/../../escaped)不会词法上折叠..路径依然带着..指向缓存目录上层真实读写时 OS 会完成解析从而逃出缓存目录。修复前的_getvaluepath()/mkdir()直接使用joinpath拼接等于把路径安全完全交给调用方自觉。四、修复核心_join_within的“归一化 包含性”双重校验本次修复引入了一个统一的入口——静态方法_join_within()cacheprovider.py#L164-L176staticmethod def _join_within(base: Path, name: str) - Path: Join name onto base, keeping the result inside base. ... path Path(os.path.normpath(base.joinpath(name))) if not path.is_relative_to(base): raise ValueError(f{name!r} is not allowed to escape the cache directory) return path其原理分两步词法归一化os.path.normpath先把拼接结果中的.与..段在纯字符串层面折叠。例如base/v/plugin/sub/../value被折叠为base/v/plugin/value绝对路径base//absolute/escaped也不会再有二次解析的余地。包含性校验Path.is_relative_to(base)归一化后再检查结果是否仍位于base之内。归一化之前逃逸的路径如../escaped、plugin/../../escaped、/absolute/escaped此时必然落在base之外直接抛出ValueError。值得注意的是一个精心保留的边界能抵消的..是允许的。plugin/sub/../value归一化后等于plugin/value仍在缓存目录内因此合法。这一行为由测试test_cache_key_normalized专门锁定testing/test_cacheprovider.py#L61-L67config.cache.set(plugin/sub/../value, 42) assert config.cache.get(plugin/value, None) 42五、三道防线逐一落地1.Cache.get()与Cache.set()key 必须解析在v/内get()和set()都通过_getvaluepath()获得目标文件路径cacheprovider.py#L198-L199def _getvaluepath(self, key: str) - Path: return self._join_within(self._cachedir / self._CACHE_PREFIX_VALUES, key)于是写入、读取都在v/子目录的边界内进行。若 key 逃逸set()在写之前、get()在读之前就会因_join_within抛出ValueError杜绝了越界写与越界读两种风险。对应测试test_cache_key_escapetesting/test_cacheprovider.py#L42-L59覆盖了四类恶意键pytest.mark.parametrize( key, [ ../escaped, # 直接向上跳 plugin/../../escaped, # 多层回退 /absolute/escaped, # 绝对路径替换 base //absolute/escaped, # 多斜杠形式 ], ) def test_cache_key_escape(self, pytester, key): with pytest.raises(ValueError): config.cache.set(key, 1) with pytest.raises(ValueError): config.cache.get(key, None)2.Cache.mkdir()双重复核mkdir()比get/set多一层前置检查cacheprovider.py#L178-L196def mkdir(self, name: str) - Path: if len(Path(name).parts) 1: raise ValueError(name is not allowed to contain path separators) res self._join_within(self._cachedir / self._CACHE_PREFIX_DIRS, name) self._mkdir(res) return res第一道name中不允许出现路径分隔符即Path(name).parts长度必须为 1。因此mkdir(key/name)直接报错见 test_config_cache_mkdir第二道通过_join_within做包含性校验专门拦截mkdir(..)这类“单个..段”的攻击。测试test_config_cache_mkdir_escapetesting/test_cacheprovider.py#L34-L40说明了为什么要两道防线并存..本身只是一个 path part能通过第一道分隔符检查必须靠第二道兜底。mkdir()的文档还要求名字带上你的插件或应用标识防止与其他缓存使用者冲突。3.--cache-showglob 匹配结果同样要过滤--cache-show支持一个可选的 glob 参数默认*用于按模式浏览缓存中的值与目录。修复前rglob()会顺着..段向缓存目录外遍历攻击者可以用--cache-show ../../../secret.json之类的方式探查缓存目录外的文件内容。修复后的cacheshow()在遍历处做了过滤cacheprovider.py#L637-L644def globfiles(base: Path) - Iterable[Path]: Glob for files under base, discarding matches that escape it. A glob may contain .. segments, which rglob() happily follows. for x in base.rglob(glob): if x.is_file() and Path(os.path.normpath(x)).is_relative_to(base): yield x先用rglob拿到候选文件再逐一对归一化后的路径做is_relative_to(base)校验只保留仍处于v/或d/之内的结果。测试test_cache_show_escaping_globtesting/test_cacheprovider.py#L319-L339验证了完整攻击场景在项目根目录放置secret.json后执行--cache-show ../../../secret.jsonglob 模式本身会回显在节标题中但s3cr3t内容、contains与is a file of length等泄漏特征全部不在输出中。另外globfiles对v/与d/两个子目录分别遍历因此--cache-show输出仍会保持 cache values for ... 与 cache directories for ... 两个分节见 test_cache_show 的断言。六、实战建议如何编写合规的缓存代码结合cachefixture 的文档字符串cacheprovider.py#L580-L593与本次修复给插件/测试作者三条可操作规范key 命名必须使用/分隔的字符串且第一个路径段通常是你的插件或应用名例如cache.set(myplugin/last_revision, rev)。这既是防冲突约定也天然降低了键名中混入..的几率。不要手动拼接路径始终通过cache.get/cache.set/cache.mkdir读写让_join_within做边界校验避免用返回的 Path 对象再与任意用户输入拼接后绕过检查。值类型set()的值必须是标准库json可处理的类型基本类型及嵌套的 list/dict。若传入不可序列化对象如Cache实例本身json.dumps会抛出TypeError对应测试 test_config_cache_dataerror。若无法读取已有缓存如 JSON 损坏get()会静默返回你传入的default。七、小结CacheAPI 的这次修复changelog/14884.bugfix.rst本质上是把“路径安全”从调用方义务收编为 API 自身契约_join_within通过normpath词法折叠 is_relative_to包含性校验统一为get/set/mkdir建立边界--cache-show则在 glob 层做同样的过滤。底层实现可继续研读 src/_pytest/cacheprovider.pyCache类与cacheshow()函数行为契约由 testing/test_cacheprovider.py 中的TestNewAPI与test_cache_show_escaping_glob完整锁定缓存机制的完整用法说明参见 doc/en/how-to/cache.rst 与 doc/en/reference/fixtures.rst。【免费下载链接】pytestThe pytest framework makes it easy to write small tests, yet scales to support complex functional testing项目地址: https://gitcode.com/GitHub_Trending/py/pytest创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考