# 12.4 路径校验与注入防护

路径校验是Harness安全中 **最复杂且最关键** 的环节。文件系统是Agent最常访问的资源，路径穿越攻击也是最易发动的攻击。一个强大的路径校验器需要应对多个层次的编码和规范化攻击。

本节从路径穿越的常见攻击向量入手，介绍Claude Code的5层递进式防护机制，并通过实际案例展示其如何应对各种攻击。

## 12.4.1 路径穿越攻击向量

### 向量1：相对路径穿越

相对路径穿越的攻击示例：

```
工作目录： /tmp/agent/
请求： ../../../etc/passwd
规范化： /etc/passwd
结果： 逃逸成功
```

### 向量2：URL编码穿越

URL编码穿越的攻击示例：

```
请求： ..%2f..%2fetc%2fpasswd
解码： ../../etc/passwd
规范化： /etc/passwd
结果： 逃逸成功
```

### 向量3：双重URL编码穿越

攻击示例如下：

```
请求： ..%252f..%252fetc%252fpasswd
首次解码： ..%2f..%2fetc%2fpasswd
验证器可能只解码一次,再调用可能二次解码
结果： 逃逸成功
```

### 向量4：Unicode标准化攻击

具体示例如下：

```
U+2044 (FRACTION SLASH,看起来像/)
"etc\u2044passwd" 规范化为 "etc/passwd"

使用不同的Unicode形式：
- NFD (Decomposed): é = e + combining acute
- NFC (Composed): é
验证后改变规范化方式可能导致绕过
```

### 向量5：反斜杠注入

攻击模式如下：

```
工作目录： C:\agents\work\
请求： ..\..\windows\system32
验证器如果只检查/, 会遗漏\
结果： 逃逸成功(Windows系统)
```

### 向量6：符号链接逃逸

具体场景如下：

```
/tmp/agent/link -> /etc/passwd (符号链接)
验证： 链接在工作目录内 ✓
访问： readlink /tmp/agent/link -> /etc/passwd
结果： 逃逸成功
```

### 向量7：Case Sensitivity/Insensitivity

案例如下：

```
验证黑名单： "/etc/passwd" 禁止
请求： "/ETC/PASSWD" (某些文件系统不区分大小写)
结果： 逃逸成功
```

## 12.4.2 Claude Code 的 pathValidation.ts - 5层防护

Claude Code实现了5层递进式防护，每一层都针对特定的攻击向量：

```python
import os
import re
from pathlib import Path
from urllib.parse import unquote
import unicodedata

class PathValidator:
    """
    5层递进式路径校验(基于Claude Code pathValidation.ts)
    """

    def __init__(self, base_path: str = "/tmp/agent"):
        self.base_path = os.path.abspath(base_path)
        self.max_path_length = 4096

    def validate(self, user_path: str) -> str:
        """
        完整的路径校验流程
        返回规范化后的安全路径,或抛异常
        """

        # 第一层：长度检查
        self._check_length(user_path)

        # 第二层：URL解码(全部解码,迭代检查)
        decoded_path = self._decode_all_encodings(user_path)

        # 第三层：Unicode规范化
        normalized_path = self._normalize_unicode(decoded_path)

        # 第四层：平台特定规范化(处理反斜杠)
        platform_normalized = self._normalize_platform(normalized_path)

        # 第五层：符号链接解析与边界检查
        resolved_path = self._resolve_and_check_boundaries(platform_normalized)

        return resolved_path

    # ============ 第一层：长度检查 ============
    def _check_length(self, path: str) -> None:
        """防止过长路径导致的缓冲区溢出或DoS"""
        if len(path) > self.max_path_length:
            raise ValueError(f"路径过长: {len(path)} > {self.max_path_length}")

    # ============ 第二层：URL解码(完全)============
    def _decode_all_encodings(self, path: str) -> str:
        """
        迭代解码直到稳定,防止多重编码攻击

        例： ..%252f -> ..%2f -> ../
        """
        previous = None
        current = path

        # 迭代解码，直到输出不再变化（收敛）。使用迭代上限防止畸形输入。
        # 注意：不能用"% 数量不变"作为提前退出条件，因为 %25xx 解码为 %xx
        # 时 % 数量恰好保持不变，会导致双重编码的路径穿越绕过。
        for _ in range(20):
            if previous == current:
                break
            previous = current
            current = unquote(current)
        else:
            # 20 次仍未收敛，视为攻击输入
            raise ValueError(f"URL 解码未收敛，疑似编码攻击：{path!r}")

        return current

    # ============ 第三层：Unicode规范化 ============
    def _normalize_unicode(self, path: str) -> str:
        """
        Unicode规范化为NFC形式,防止利用不同Unicode形式的绕过

        例： é (U+00E9) 和 e (U+0065) + ◌́ (U+0301) 看起来相同
        都规范化为 NFC: é
        """
        # NFC: 标准分解形式,最常用于文件系统
        return unicodedata.normalize('NFC', path)

    # ============ 第四层：平台特定规范化 ============
    def _normalize_platform(self, path: str) -> str:
        """
        处理平台特定字符
        - Windows: 反斜杠 \ 等同于 /
        - 移除 ./ 当前目录引用
        """
        # 统一使用正斜杠
        path = path.replace('\\', '/')

        # 移除重复的 //
        while '//' in path:
            path = path.replace('//', '/')

        # 使用 os.path.normpath 安全地处理 ./ 和冗余分隔符
        # 注意：不能简单使用 str.replace('./', ''),因为会误伤合法路径
        # 如 "notes./backup" 中的 "./" 并非目录引用
        import posixpath
        path = posixpath.normpath(path)

        return path

    # ============ 第五层：符号链接解析与边界检查 ============
    def _resolve_and_check_boundaries(self, path: str) -> str:
        """
        1. 将相对路径转为绝对路径(相对于base_path)
        2. 解析所有 .. 和 符号链接
        3. 检查最终路径是否仍在base_path内
        """

        # 如果是相对路径,相对于base_path解析
        if not path.startswith('/'):
            full_path = os.path.join(self.base_path, path)
        else:
            full_path = path

        # 使用realpath解析符号链接和..
        # 这是关键步骤：会实际访问文件系统
        try:
            resolved = os.path.realpath(full_path)
        except OSError as e:
            # 如果路径不存在或无法访问,仍然进行规范化
            # 使用abspath代替realpath(不解析符号链接)
            resolved = os.path.abspath(full_path)

        # 规范化base_path
        base_resolved = os.path.realpath(self.base_path)

        # 关键检查：resolved 路径必须在 base_path 内。
        # 使用 commonpath 避免 "/tmp/agent-evil" 或 "..notes" 这类前缀误判。
        if os.path.commonpath([base_resolved, resolved]) != base_resolved:
            raise ValueError(
                f"路径逃逸: {resolved} 不在允许范围 {base_resolved} 内"
            )

        return resolved

    # 辅助方法：白名单检查
    def validate_with_whitelist(self,
                               user_path: str,
                               whitelist: list) -> str:
        """
        可选的白名单检查：仅允许特定目录
        """
        resolved = self.validate(user_path)

        for allowed_dir in whitelist:
            allowed_resolved = os.path.realpath(allowed_dir)
            if resolved.startswith(allowed_resolved + '/') or resolved == allowed_resolved:
                return resolved

        raise ValueError(f"路径 {resolved} 不在白名单内")
```

## 12.4.3 攻击向量验证

让我们用上述PathValidator来验证它对各种攻击的防护：

```python
# 创建测试环境
import tempfile
import os

def test_path_validator():
    """测试路径校验器"""

    # 创建临时目录作为base_path
    with tempfile.TemporaryDirectory() as tmpdir:
        base = os.path.join(tmpdir, "agent")
        os.makedirs(base)

        # 创建一些文件用于测试
        safe_dir = os.path.join(base, "data")
        os.makedirs(safe_dir)
        with open(os.path.join(safe_dir, "safe.txt"), "w") as f:
            f.write("safe content")

        # 在base外创建一个要保护的文件
        protected = os.path.join(tmpdir, "protected.txt")
        with open(protected, "w") as f:
            f.write("protected content")

        # 创建符号链接(Linux)
        try:
            symlink = os.path.join(base, "link_to_protected")
            os.symlink(protected, symlink)
        except:
            symlink = None

        validator = PathValidator(base_path=base)

        # 测试用例
        test_cases = [
            # (输入, 应该成功, 描述)
            ("data/safe.txt", True, "合法的相对路径"),
            ("./data/safe.txt", True, "带./的合法路径"),
            ("data/../data/safe.txt", True, "包含..但仍在范围内"),
            ("../../../etc/passwd", False, "相对路径穿越"),
            ("..%2f..%2fetc%2fpasswd", False, "URL编码穿越"),
            ("..%252f..%252fetc%252fpasswd", False, "双重URL编码穿越"),
            ("data/../../../../../../etc/passwd", False, "多层相对穿越"),
        ]

        for user_path, should_succeed, description in test_cases:
            try:
                result = validator.validate(user_path)
                if should_succeed:
                    print(f"✓ {description}: {user_path} -> {result}")
                else:
                    print(f"✗ {description}: {user_path} 本应被阻止,但成功了")
            except ValueError as e:
                if not should_succeed:
                    print(f"✓ {description}: {user_path} 正确被阻止 ({e})")
                else:
                    print(f"✗ {description}: {user_path} 本应成功,但被拒绝了 ({e})")

# 运行测试
test_path_validator()
```

## 12.4.4 路径校验在工具调用中的应用

实现如下：

```python
from typing import Dict, Any

class ToolCallValidator:
    """工具调用中集成路径校验"""

    def __init__(self, path_validator: PathValidator):
        self.path_validator = path_validator

    def validate_tool_call(self,
                          tool_call: "ToolCall",
                          tool_schema: Dict[str, Any]) -> None:
        """
        在执行工具前,对所有路径类参数进行校验

        假设tool_schema中有"path"字段标记哪些参数是路径
        """

        for param_name, param_value in tool_call.args.items():
            # 检查此参数是否是路径类型
            if param_name in tool_schema.get("path_params", []):
                if isinstance(param_value, str):
                    try:
                        # 校验路径
                        safe_path = self.path_validator.validate(param_value)
                        # 替换为规范化的路径(可选)
                        tool_call.args[param_name] = safe_path
                    except ValueError as e:
                        raise PermissionDenied(f"路径校验失败: {e}")

# 工具schema示例
TOOL_SCHEMAS = {
    "read_file": {
        "description": "读取文件",
        "parameters": {
            "path": {"type": "string", "description": "文件路径"}
        },
        "path_params": ["path"]  # 标记哪些参数是路径
    },
    "list_directory": {
        "description": "列出目录",
        "parameters": {
            "path": {"type": "string", "description": "目录路径"}
        },
        "path_params": ["path"]
    },
    "bash": {
        "description": "执行bash命令",
        "parameters": {
            "command": {"type": "string", "description": "bash命令"}
        },
        "path_params": []  # bash命令中的路径需单独检查
    }
}

# 使用
validator = ToolCallValidator(PathValidator(base_path="/tmp/agent"))

tool_call = ToolCall(
    tool_name="read_file",
    args={"path": "../../../etc/passwd"}
)

try:
    validator.validate_tool_call(tool_call, TOOL_SCHEMAS["read_file"])
except PermissionDenied as e:
    print(f"工具调用被拒绝: {e}")
```

## 12.4.5 路径校验的性能考虑

路径校验涉及多次字符串操作和可能的文件系统调用(realpath)，需要考虑性能。然而，**LRU缓存带来的TOCTOU(Time-of-Check-Time-of-Use)窗口不可忽视**：缓存在validate()时通过，但从缓存命中到实际访问文件之间，文件系统状态可能发生变化（如符号链接被修改、权限改变、文件被删除等）。这个时间窗口虽然通常很小(毫秒级)，但在高并发或对手主动利用的场景中可能被exploited。

建议：

* 缓存TTL应设置较短(≤1秒)，权衡性能与风险
* 关键安全操作（如删除、修改权限）应禁用缓存，每次都调用realpath
* 定期重新验证长生命周期的路径

```python
import time
from functools import lru_cache

class CachedPathValidator(PathValidator):
    """带缓存的路径校验器

    警告：缓存可能导致TOCTOU(检查-使用时间差)风险。
    缓解建议：(1)缓存TTL <= 1秒; (2)关键操作禁用缓存; (3)定期验证。
    """

    def __init__(self, base_path: str = "/tmp/agent", cache_size: int = 1000, cache_ttl: int = 1):
        super().__init__(base_path)
        self.cache_size = cache_size
        self.cache_ttl = cache_ttl  # TTL in seconds
        # 使用LRU缓存,注意必须包装父类方法以避免无限递归
        self._validate_cached = lru_cache(maxsize=cache_size)(super().validate)

    def validate(self, user_path: str) -> str:
        """使用缓存的validate"""
        return self._validate_cached(user_path)

    def clear_cache(self):
        """清空缓存"""
        self._validate_cached.cache_clear()

# 性能测试
validator = CachedPathValidator()

paths = ["data/file1.txt", "data/file2.txt"] * 500  # 重复路径

start = time.time()
for path in paths:
    try:
        validator.validate(path)
    except:
        pass
elapsed = time.time() - start

print(f"校验{len(paths)}个路径耗时: {elapsed:.3f}秒")
print(f"缓存命中率: {validator._validate_cached.cache_info()}")
```

## 12.4.6 路径校验最佳实践

1. **始终使用realpath**: 解析符号链接，防止链接逃逸
2. **规范化顺序**: URL解码 → Unicode规范化 → 平台规范化 → realpath
3. **白名单优先**: 相比黑名单，白名单更安全
4. **日志和审计**: 记录所有被拒绝的路径尝试
5. **缓存路径校验**: realpath调用昂贵，使用LRU缓存
6. **定期审查**: 新的编码方式不断出现，定期更新防护

```yaml
## 路径校验配置示例
path_validation:
  enabled: true
  base_path: "/tmp/agent"
  max_path_length: 4096

  # 白名单目录
  whitelist_dirs:
    - "/tmp/agent/data"
    - "/tmp/agent/output"
    - "/tmp/agent/cache"

  # 黑名单目录(绝对黑名单)
  blacklist_dirs:
    - "/etc"
    - "/root"
    - "/var/log"
    - "/.ssh"

  # 缓存设置
  cache:
    enabled: true
    max_size: 10000
    ttl_seconds: 3600

  # 日志
  audit_log: true
```

***

**本节总结**：路径校验是防护路径穿越的关键。通过 5 层递进式防护（长度、解码、Unicode、平台、realpath），可以有效应对多种已知和未知的编码攻击。关键是：**规范化后必须使用 realpath，并检查最终路径是否在边界内**。


---

# Agent Instructions: Querying This Documentation

If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter:

```
GET https://yeasy.gitbook.io/harness_engineering_guide/di-si-bu-fen-an-quan-ping-gu-yu-yan-jin/12_security/12.4_path_validation.md?ask=<question>
```

The question should be specific, self-contained, and written in natural language.
The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
