mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-26 20:33:15 +00:00
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 并前向传播。
110 lines
3.3 KiB
Go
110 lines
3.3 KiB
Go
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)
|
||
}
|
||
}
|