公司动态

【Bug已解决】Filesystem file search rejects valid path segments containing double dots

📅 2026/8/18 9:22:42
【Bug已解决】Filesystem file search rejects valid path segments containing double dots
【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(frozenTrue) 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_dotTrue).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 守护合法..文件名被放行、真实穿越被拒。路径安全的通用原则按解析后的路径段判断是否越界而非按文件名长相做字符串启发式。