【Bug已解决】Filesystem file search rejects valid path segments containing double dots
一、现象长什么样
某文件系统的文件搜索工具(比如 agent 用来"在目录里找文件"的FileSystem工具)带一个防路径穿越(path traversal)的保护:拒绝任何包含..的路径,防止用户/模型读到上级目录。但这个保护过激了——它把"路径里出现..子串"一律拒绝,连合法的、非穿越的..用法也挡掉了:
- 以
..开头的隐藏文件,如..bashrc(少见但合法)、..hidden。 - 文件名中间含连续两点,如
report..final.md、a..b.txt、v1..2(用户用..当分隔符)。 - 某些工具把
..用于"相对上移"以外语义,比如版本或命名约定。
结果:用户明明要搜一个就叫report..final.md的文件,工具直接ValueError: path traversal detected拒绝,明明没有穿越意图。过度防御变成了功能缺陷。
二、背景
路径穿越防护的标准写法是:解析路径后检查是否有段等于..(即os.path.normpath之后是否跳出 base)。但偷懒的实现直接做字符串检查:
if ".." in path: raise ValueError("path traversal detected")".." in path会匹配任何包含连续两点的字符串,包括report..final.md。真正该防的是"..作为路径段(被/包围或位于段首)",而不是"..作为子串"。
合法文件名里..完全可能出现(Unix 允许文件名包含几乎任何字符,除了/和 NUL)。所以"含..即拒绝"是错误的启发式。
三、根因
根因是把"子串包含"当成"路径段穿越":
- 子串匹配误判:
".." in path命中所有连续两点,不论其是否构成路径段。 - 未做路径归一化再判段:正确做法是用
os.path.normpath/PurePosixPath解析,再检查每个段是否等于"..",或解析后是否仍在 base 内。 - 混淆"文件名里的
.."与"目录里的..":前者是普通字符,后者才是穿越。
本质:用字符串启发式替代了正确的路径语义解析,把合法的命名字符当成了攻击特征。
四、最小可运行复现
下面缩略逻辑复现误拒:
def bad_safe(path): if ".." in path: # 错误:子串匹配 raise ValueError("path traversal detected") return path for p in ["report..final.md", "../etc/passwd", "a..b.txt"]: try: print(p, "->", bad_safe(p)) except ValueError as e: print(p, "-> REJECTED:", e) # report..final.md -> REJECTED (合法却被拒) # ../etc/passwd -> REJECTED (正确拒绝) # a..b.txt -> REJECTED (合法却被拒)修复:用路径归一化,只在"段等于 .. 且导致越界"时拒绝。
from pathlib import Path def good_safe(path, base): resolved = (base / path).resolve() base_resolved = Path(base).resolve() if base_resolved not in resolved.parents and resolved != base_resolved: raise ValueError("path escapes base") return resolved五、解决方案(第一层:最小直接修复)
最小修法:放弃子串检查,改为路径解析后判断"是否仍在 base 之内",..只有当它真的导致跳出 base 时才拒绝;文件名里的..不受影响。
from pathlib import Path def is_safe_under(path: str, base: str) -> bool: base_r = Path(base).resolve() target = (base_r / path).resolve() return base_r in target.parents or target == base_r def search_file(path, base): if not is_safe_under(path, base): raise ValueError("path escapes base directory") # 继续搜索 ...这一层让report..final.md正常通过,../etc/passwd正确拒绝。
六、解决方案(第二层:结构化改进)
把"路径穿越判定规则"固化成策略对象,作为单一事实来源,明确按段解析而非子串。
from dataclasses import dataclass from pathlib import Path from typing import List @dataclass(frozen=True) class LangChainFilesystemSearchPolicy: """文件系统搜索路径安全策略的单一事实来源。""" forbid_substring_double_dot: bool = False # 必须为 False,否则误拒 resolve_and_contain: bool = True allow_dotfiles: bool = True def is_safe(self, path: str, base: str) -> bool: if self.forbid_substring_double_dot: raise AssertionError("substring '..' check causes false rejects") base_r = Path(base).resolve() target = (base_r / path).resolve() return self.resolve_and_contain and ( base_r in target.parents or target == base_r ) def validate(self) -> None: if self.forbid_substring_double_dot: raise AssertionError("must use segment-based check, not substring")搜索工具用policy.is_safe,安全性由"解析后是否越界"决定,而非文件名长什么样。
七、解决方案(第三层:断言 / CI 守护)
用 pytest 锁死正确判定:
import pytest from pathlib import Path from policy import LangChainFilesystemSearchPolicy as P def test_legal_double_dot_allowed(tmp_path): p = P() (tmp_path / "report..final.md").write_text("x") assert p.is_safe("report..final.md", str(tmp_path)) is True def test_traversal_rejected(tmp_path): p = P() assert p.is_safe("../etc/passwd", str(tmp_path)) is False def test_no_substring_check(): with pytest.raises(AssertionError): P(forbid_substring_double_dot=True).validate() def test_dotfile_allowed(tmp_path): p = P() (tmp_path / "..hidden").write_text("x") assert p.is_safe("..hidden", str(tmp_path)) is TrueCI 加一条:FileSystem搜索单测必须覆盖"文件名含.."的合法用例,断言不被拒;并覆盖真实穿越用例,断言被拒。
八、排查清单
- 搜
report..final.md被拒?→ 搜索用了".." in path子串检查,误判。 - 文件名里的
..是否合法?→ Unix 文件名可含..,非穿越。 - 是否做了路径归一化?→ 用
Path.resolve()再判断是否越界 base。 - 真穿越(
../)是否被拒?→ 归一化后段为..且越界,应拒。 - CI 是否禁止子串检查?→ 扫描
".." in路径校验。 - 隐藏文件
..x是否放行?→ 应放行(合法命名)。
九、小结
文件系统文件搜索把"路径里出现..子串"一律拒绝,误伤了文件名中合法含..的情况(如report..final.md、隐藏文件..hidden),过度防御变成功能缺陷。根因是用字符串子串匹配替代了正确的路径语义解析。第一层改为路径归一化后判断是否越界 base;第二层用LangChainFilesystemSearchPolicy把判定规则固化成单一事实来源并禁止子串检查;第三层用 pytest 守护合法..文件名被放行、真实穿越被拒。路径安全的通用原则:按解析后的路径段判断是否越界,而非按文件名长相做字符串启发式。