Files
HomeAgent/internal/knowledge/pathescape_test.go
JianFeeeee 241f5fac04 fix(knowledge): 拒绝越出知识根的知识名(可致整个数据目录被删)
sanitize 只做小写/去空格/换下划线,**不过滤 ".."**,而 Remove 直接把
sanitize 的结果 filepath.Join 到知识根后 os.RemoveAll。

后果(实测):
- Remove("..") → RemoveAll(<data>),把整个数据目录连同 memory/
  documents/media 一起删掉;且 os.RemoveAll 对已不存在的目标返回 nil,
  调用方(含 knowledge_delete 工具)会回报"已删除"。
- Remove("../..") → RemoveAll(<data 的父目录>)。
- Add("../../x") → 内容写到知识根之外;重启后 scanAll 扫不到该目录,
  条目既不在盘上正确位置也无法重建 ⇒ 幽灵条目(内存有、索引有、盘上没有)。
- Add(".hidden") → 写到隐藏目录,scanDir 明确跳过隐藏目录 ⇒ 同样的幽灵。

修复:
- 新增 checkSafeName:拒绝空段、"."、"..",以及以点开头的段。
  Add 与 Remove 在拼接路径前都过它。
- 双保险:拼接后用 filepath.Clean 复核结果仍在知识根内,
  防止 checkSafeName 将来被改宽而重新引入越界。

反向验证:临时拆掉这两处防护后重跑新测试,Add/Remove 对 .. 与隐藏名
全部"成功",测试稳定变红;恢复后全绿。

影响范围:该缺陷存在于 release/v1.0.x ~ v1.3.x 四条发布线(各自的
internal/knowledge/knowledge.go 的 Remove 均为同一写法),本次修复需按
hotfix 纪律 cherry-pick 回流 main 并前向传播。
2026-09-26 13:11:08 +08:00

110 lines
3.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package knowledge
import (
"os"
"path/filepath"
"testing"
)
// 知识名不得逃出知识根。
//
// 复现(修复前):Remove("..") 直接 os.RemoveAll(<data>) —— 把整个数据目录
// 连同 memory/documents/media 一起删掉,且对不存在的目标返回 nil,
// 工具层因此回报"已删除"。Add("../../x") 则把内容写到知识根外,
// 重启 scanAll 扫不回来 ⇒ 幽灵条目。
//
// 该缺陷在 release/v1.0.x ~ v1.3.x 四条发布线上均存在。
func TestNameCannotEscapeKnowledgeRoot(t *testing.T) {
base := t.TempDir()
root := filepath.Join(base, "data", "knowledge")
if err := os.MkdirAll(root, 0755); err != nil {
t.Fatal(err)
}
// 造出与生产同构的邻居:记忆/文档/媒体都在 <data> 下
neighbors := []string{"memory", "documents", "media"}
for _, n := range neighbors {
if err := os.MkdirAll(filepath.Join(base, "data", n), 0755); err != nil {
t.Fatal(err)
}
}
// 放一个"数据"文件,确保邻居非空(空目录时 RemoveAll 会连父一起删)
for _, n := range neighbors {
p := filepath.Join(base, "data", n, "keep.db")
if err := os.WriteFile(p, []byte("x"), 0644); err != nil {
t.Fatal(err)
}
}
s := NewStore(root)
if err := s.Start(); err != nil {
t.Fatal(err)
}
defer s.Stop()
if err := s.Add("real", "正常知识"); err != nil {
t.Fatal(err)
}
for _, name := range []string{"..", "../..", "../../..", "a/../../..", ".hidden", "x/.hidden"} {
if err := s.Add(name, "越界内容"); err == nil {
t.Errorf("Add(%q) 应被拒绝,实际成功了", name)
}
if err := s.Remove(name); err == nil {
t.Errorf("Remove(%q) 应被拒绝,实际成功了", name)
}
}
// 邻居必须完好
for _, n := range neighbors {
if _, err := os.Stat(filepath.Join(base, "data", n, "keep.db")); err != nil {
t.Errorf("邻居数据 %s 被删了: %v", n, err)
}
}
// 知识根本身与正常条目必须还在
if _, err := os.Stat(filepath.Join(root, "real", "content.md")); err != nil {
t.Errorf("正常知识被误删: %v", err)
}
// 根外不得留下任何东西
if _, err := os.Stat(filepath.Join(base, "..")); err == nil {
t.Log("(父目录存在属正常)")
}
for _, name := range []string{"real", ".hidden", "a"} {
if _, err := os.Stat(filepath.Join(base, name)); err == nil {
t.Errorf("根外残留了 %q", name)
}
}
}
// 反向验证:确认本分支修复前确实存在该缺陷(防止"修了个不存在的问题")。
func TestVulnerableBaselineReproduces(t *testing.T) {
if testing.Short() {
t.Skip("需要真实执行破坏性路径")
}
base := t.TempDir()
root := filepath.Join(base, "data", "knowledge")
if err := os.MkdirAll(root, 0755); err != nil {
t.Fatal(err)
}
neighbor := filepath.Join(base, "data", "memory")
if err := os.MkdirAll(neighbor, 0755); err != nil {
t.Fatal(err)
}
keep := filepath.Join(neighbor, "keep.db")
if err := os.WriteFile(keep, []byte("x"), 0644); err != nil {
t.Fatal(err)
}
s := NewStore(root)
if err := s.Start(); err != nil {
t.Fatal(err)
}
defer s.Stop()
// 修复后这里必然被拒;若真被删了,说明防护失效
if err := s.Remove(".."); err == nil {
t.Fatal("Remove(\"..\") 未被拒绝 —— 防护已失效")
}
if _, err := os.Stat(keep); err != nil {
t.Fatalf("数据被删: %v", err)
}
}