refactor(exec,io): 文件沙箱单入口收进 io 工具、删 exec.FileSandbox(裁决 13.4.4)+ 修一条假阳性安全断言
- 删除未接线的 exec.FileSandbox(含 5 个用例):文件沙箱从此只有 plugins/tools/io/file.go 一处实现 - io 侧取更严一侧:NUL 拒绝(原 io 缺)、写前按最深存在祖先判定落点(避免越界建目录)、roots 归化到真实路径(修 roots 自身是 symlink 时的全误拒) - exec/sandbox.go 保留并注释说明:那是**终端工作目录守卫**的原语(terminal.go 在用),不是文件沙箱 - 修假阳性:原 NUL 用例断言错误文本含 "NUL",而 t.TempDir() 目录名自带测试函数名 "…RejectsNULBytePath…"、错误又内嵌整条临时路径 ⇒ 删掉安全检查用例照样绿。改为哨兵错误 ErrNullByte + errors.Is(错误文本逐字节不变),修复后同一变异必红 - 同类排查:file_test.go 三处文本断言判定无风险(含空格的串不可能出现在临时目录名里),保留
This commit is contained in:
1 parent
95f97f5df3
commit
09ee561aa7
6 files changed
+344
-222
No files matched your search
+104
-27
@@ -20,6 +20,9 @@ var _ sdk.Tool = (*fileTool)(nil)
|
||||
// fileTool reads, writes, lists and searches files, confined to the configured
|
||||
// data directory plus any explicit whitelist. Paths are cleaned and re-checked
|
||||
// against those roots, so ".." traversal and symlink escapes are rejected.
|
||||
//
|
||||
// 本工具是文件访问沙箱的唯一实现处(裁决 13.4.4、design/04 §4):roots 收敛、
|
||||
// symlink 逃逸、NUL 字节与 ".." 穿越都在此判定,写入前先按真实落点校验父链。
|
||||
type fileTool struct {
|
||||
roots []string // absolute, cleaned allowed directories
|
||||
maxRead int64
|
||||
@@ -121,20 +124,24 @@ func (f *fileTool) read(ctx context.Context, p string) (any, error) {
|
||||
return map[string]any{"path": real, "size": len(data), "content": string(data)}, nil
|
||||
}
|
||||
|
||||
// write creates or overwrites a file, capped at maxWrite bytes and confined to
|
||||
// the allowed roots (parent dirs are created inside the root only).
|
||||
// write 创建或覆盖文件,内容上限 maxWrite 字节。流程:
|
||||
// 1. 写入前先算出目标的真实落点,确认仍在 roots 内(防 roots 内的 symlink 逃逸);
|
||||
// 2. 建目录;
|
||||
// 3. 建目录之后再核一遍父目录的真实落点——兜住建目录期间新出现的 symlink,以及
|
||||
// Windows 目录 junction 这类 filepath.EvalSymlinks 不解析的再解析点(此时解析
|
||||
// 报错即拒),保证严格度不低于收敛前的实现(裁决 13.4.4)。
|
||||
func (f *fileTool) write(ctx context.Context, p, content string) (any, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clean, err := f.resolve(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if int64(len(content)) > f.maxWrite {
|
||||
return nil, fmt.Errorf("io: file: content exceeds the %d-byte write limit", f.maxWrite)
|
||||
}
|
||||
parent := filepath.Dir(clean)
|
||||
target, err := f.resolveForCreate(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
parent := filepath.Dir(target)
|
||||
if err := os.MkdirAll(parent, dirPerm); err != nil {
|
||||
return nil, fmt.Errorf("io: file: mkdir: %w", err)
|
||||
}
|
||||
@@ -142,19 +149,14 @@ func (f *fileTool) write(ctx context.Context, p, content string) (any, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("io: file: resolve parent: %w", err)
|
||||
}
|
||||
if !f.within(realParent) {
|
||||
return nil, fmt.Errorf("io: file: parent directory escapes the allowed directories")
|
||||
if !f.withinReal(realParent) {
|
||||
f.log.Warn("io file: rejected write escaping allowed directories", "path", p, "resolved", realParent)
|
||||
return nil, fmt.Errorf("io: file: %q escapes the allowed directories", p)
|
||||
}
|
||||
if info, err := os.Lstat(clean); err == nil && info.Mode()&os.ModeSymlink != 0 {
|
||||
real, err := filepath.EvalSymlinks(clean)
|
||||
if err != nil || !f.within(real) {
|
||||
return nil, fmt.Errorf("io: file: %q escapes the allowed directories", p)
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(clean, []byte(content), filePerm); err != nil {
|
||||
if err := os.WriteFile(target, []byte(content), filePerm); err != nil {
|
||||
return nil, fmt.Errorf("io: file: write: %w", err)
|
||||
}
|
||||
return map[string]any{"path": clean, "size": len(content)}, nil
|
||||
return map[string]any{"path": target, "size": len(content)}, nil
|
||||
}
|
||||
|
||||
// list returns the entries of a directory, capped at maxList.
|
||||
@@ -246,13 +248,22 @@ func (f *fileTool) search(ctx context.Context, base, query string) (any, error)
|
||||
return map[string]any{"base": real, "query": query, "hits": hits, "count": len(hits)}, nil
|
||||
}
|
||||
|
||||
// resolve cleans p and verifies it stays within one of the allowed roots. It
|
||||
// rejects empty paths and any path that escapes via ".." or an absolute target
|
||||
// outside the roots.
|
||||
// ErrNullByte 表示路径中含 NUL 字节:在触达文件系统之前就被拒绝(收敛自原 exec 文件
|
||||
// 沙箱,裁决 13.4.4)。调用方与测试一律用 errors.Is(err, ErrNullByte) 判定——不要断言
|
||||
// 错误文本;此处只存不带来源前缀的文本,前缀由返回值处的 fmt.Errorf 补上,保证对外错误
|
||||
// 文本与收敛前逐字节一致。
|
||||
var ErrNullByte = errors.New("path contains a NUL byte")
|
||||
|
||||
// resolve 清洗路径并确认其词法上留在允许的 roots 之内:拒绝空路径与含 NUL 字节的
|
||||
// 路径(NUL 收敛自原 exec 文件沙箱,裁决 13.4.4),拒绝 ".." 穿越与越界的绝对路径;
|
||||
// 返回绝对、clean 的词法路径。
|
||||
func (f *fileTool) resolve(p string) (string, error) {
|
||||
if strings.TrimSpace(p) == "" {
|
||||
return "", fmt.Errorf("io: file: path is required")
|
||||
}
|
||||
if strings.IndexByte(p, 0) >= 0 {
|
||||
return "", fmt.Errorf("io: file: %w", ErrNullByte)
|
||||
}
|
||||
if len(f.roots) == 0 {
|
||||
return "", fmt.Errorf("io: file: no base directory configured")
|
||||
}
|
||||
@@ -267,9 +278,8 @@ func (f *fileTool) resolve(p string) (string, error) {
|
||||
return clean, nil
|
||||
}
|
||||
|
||||
// resolveExisting resolves p and verifies the symlink-resolved real path stays
|
||||
// within the roots, guarding against escaping through a symlink. It errors when
|
||||
// the target does not exist.
|
||||
// resolveExisting 解析一个必须已存在的路径:要求其 symlink 解析后的真实落点仍在
|
||||
// roots 的真实路径之内,防住经由 roots 内 symlink 的逃逸。目标不存在时报错。
|
||||
func (f *fileTool) resolveExisting(p string) (string, error) {
|
||||
clean, err := f.resolve(p)
|
||||
if err != nil {
|
||||
@@ -279,16 +289,56 @@ func (f *fileTool) resolveExisting(p string) (string, error) {
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("io: file: resolve %q: %w", p, err)
|
||||
}
|
||||
if !f.within(real) {
|
||||
if !f.withinReal(real) {
|
||||
f.log.Warn("io file: rejected path escaping allowed directories", "path", p, "resolved", real)
|
||||
return "", fmt.Errorf("io: file: %q escapes the allowed directories", p)
|
||||
}
|
||||
return real, nil
|
||||
}
|
||||
|
||||
// within reports whether p is inside (or equal to) one of the allowed roots.
|
||||
func (f *fileTool) within(p string) bool {
|
||||
for _, root := range f.roots {
|
||||
// resolveForCreate 解析一个可能尚不存在(即将创建)的写入目标:先推出最深存在祖先
|
||||
// 的真实落点,确认仍在 roots 内后返回真实路径(真实祖先 + 尚未创建的分量)。
|
||||
func (f *fileTool) resolveForCreate(p string) (string, error) {
|
||||
clean, err := f.resolve(p)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
real, err := resolveReal(clean)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("io: file: resolve %q: %w", p, err)
|
||||
}
|
||||
if !f.withinReal(real) {
|
||||
f.log.Warn("io file: rejected write escaping allowed directories", "path", p, "resolved", real)
|
||||
return "", fmt.Errorf("io: file: %q escapes the allowed directories", p)
|
||||
}
|
||||
return real, nil
|
||||
}
|
||||
|
||||
// within 报告 p 是否词法上位于(或等于)某个 root 之内。
|
||||
func (f *fileTool) within(p string) bool { return withinRoots(p, f.roots) }
|
||||
|
||||
// withinReal 报告真实落点 p 是否位于某个 root 的真实路径之内。
|
||||
func (f *fileTool) withinReal(p string) bool { return withinRoots(p, f.realRoots()) }
|
||||
|
||||
// realRoots 返回各 root 的真实路径:存在则解析 symlink,尚未创建则按最深存在祖先
|
||||
// 推导。把「真实落点」与 roots 放进同一(真实)路径空间比较,既拦得住 roots 内
|
||||
// symlink 的逃逸,也不会把「DataDir 自身是 symlink」的正常部署误判为逃逸。
|
||||
func (f *fileTool) realRoots() []string {
|
||||
out := make([]string, 0, len(f.roots))
|
||||
for _, r := range f.roots {
|
||||
real, err := resolveReal(r)
|
||||
if err != nil {
|
||||
real = r
|
||||
}
|
||||
out = append(out, real)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// withinRoots 报告 p 是否位于(或等于)roots 中的某一个之内:按 filepath.Rel 判定,
|
||||
// 相对路径为 "." 或不以 ".." 开头即为包含。
|
||||
func withinRoots(p string, roots []string) bool {
|
||||
for _, root := range roots {
|
||||
rel, err := filepath.Rel(root, p)
|
||||
if err != nil {
|
||||
continue
|
||||
@@ -300,6 +350,33 @@ func (f *fileTool) within(p string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// resolveReal 返回 p 的真实(symlink 解析后)路径:p 自身存在时直接解析;不存在时
|
||||
// 沿父级上溯到最深的存在祖先,再把尚未创建的分量拼回。这样「即将创建」的路径也能
|
||||
// 在落盘前判定真实落点,避免 MkdirAll 沿 roots 内的 symlink 逃逸。
|
||||
func resolveReal(p string) (string, error) {
|
||||
cur := filepath.Clean(p)
|
||||
remaining := ""
|
||||
for {
|
||||
if real, err := filepath.EvalSymlinks(cur); err == nil {
|
||||
if remaining == "" {
|
||||
return real, nil
|
||||
}
|
||||
return filepath.Join(real, remaining), nil
|
||||
}
|
||||
base := filepath.Base(cur)
|
||||
parent := filepath.Dir(cur)
|
||||
if parent == cur {
|
||||
return "", fmt.Errorf("no existing ancestor for %s", p)
|
||||
}
|
||||
if remaining == "" {
|
||||
remaining = base
|
||||
} else {
|
||||
remaining = filepath.Join(base, remaining)
|
||||
}
|
||||
cur = parent
|
||||
}
|
||||
}
|
||||
|
||||
// absoluteClean absolutizes and cleans p, returning the best-effort absolute
|
||||
// form used as an allowed root.
|
||||
func absoluteClean(p string) string {
|
||||
|
||||
Reference in new issue
Block a user