diff --git a/internal/blobstore/blobstore.go b/internal/blobstore/blobstore.go index 5c18263..29bdd65 100644 --- a/internal/blobstore/blobstore.go +++ b/internal/blobstore/blobstore.go @@ -7,6 +7,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "sync" ) @@ -31,8 +32,30 @@ func NewLocal(dir string) (*Local, error) { return &Local{dir: dir}, nil } +// validID 只接受纯文件名形态的对象 id:非空、不含路径分隔符、非 . / ..。 +// Put 的 id 可能是上层透传的用户输入(如工件路径参数),先在此挡住路径穿越。 +func validID(id string) bool { + if id == "" || id == "." || id == ".." || filepath.IsAbs(id) { + return false + } + return !strings.ContainsAny(id, `/\`) && !strings.ContainsRune(id, 0) +} + +// contains 判断路径是否落在对象目录内:Get/Delete 收的是入库引用, +// 做包含性检查作为纵深防御,引用被篡改时也不读/删目录外文件。 +func (l *Local) contains(p string) bool { + rel, err := filepath.Rel(l.dir, filepath.Clean(p)) + if err != nil { + return false + } + return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) +} + // Put 临时文件 → rename 原子写:崩溃只可能残留 .tmp 孤立文件,不产生半写对象。 func (l *Local) Put(id string, content []byte) (string, error) { + if !validID(id) { + return "", fmt.Errorf("blobstore: invalid object id %q", id) + } final := filepath.Join(l.dir, id) tmp := final + ".tmp-" + id if err := os.WriteFile(tmp, content, 0o644); err != nil { @@ -48,6 +71,9 @@ func (l *Local) Put(id string, content []byte) (string, error) { } func (l *Local) Get(ref string) ([]byte, error) { + if !l.contains(ref) { + return nil, fmt.Errorf("blobstore: ref %s escapes object store", filepath.Base(ref)) + } b, err := os.ReadFile(ref) if os.IsNotExist(err) { return nil, fmt.Errorf("blobstore: object %s not found", filepath.Base(ref)) @@ -56,6 +82,9 @@ func (l *Local) Get(ref string) ([]byte, error) { } func (l *Local) Delete(ref string) error { + if !l.contains(ref) { + return fmt.Errorf("blobstore: ref %s escapes object store", filepath.Base(ref)) + } err := os.Remove(ref) if os.IsNotExist(err) { return nil diff --git a/internal/blobstore/blobstore_test.go b/internal/blobstore/blobstore_test.go new file mode 100644 index 0000000..b4fff04 --- /dev/null +++ b/internal/blobstore/blobstore_test.go @@ -0,0 +1,71 @@ +package blobstore + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func newTestLocal(t *testing.T) *Local { + t.Helper() + l, err := NewLocal(t.TempDir()) + if err != nil { + t.Fatalf("NewLocal: %v", err) + } + return l +} + +func TestLocalPutRejectsUnsafeIDs(t *testing.T) { + l := newTestLocal(t) + for _, id := range []string{"", ".", "..", "../evil", "../../etc/passwd", "/abs", "a/b", `a\b`, "id\x00x"} { + if _, err := l.Put(id, []byte("x")); err == nil { + t.Fatalf("Put(%q) 应拒绝却成功了", id) + } + } + entries, err := os.ReadDir(l.dir) + if err != nil { + t.Fatal(err) + } + if len(entries) != 0 { + t.Fatalf("对象目录不应有残留: %v", entries) + } +} + +func TestLocalPutGetDeleteRoundtrip(t *testing.T) { + l := newTestLocal(t) + ref, err := l.Put("art_abc123", []byte("hello")) + if err != nil { + t.Fatalf("Put: %v", err) + } + if !strings.HasPrefix(filepath.Base(ref), "art_abc123") { + t.Fatalf("ref 意外: %q", ref) + } + got, err := l.Get(ref) + if err != nil { + t.Fatalf("Get: %v", err) + } + if string(got) != "hello" { + t.Fatalf("内容不符: %q", got) + } + if err := l.Delete(ref); err != nil { + t.Fatalf("Delete: %v", err) + } + if _, err := l.Get(ref); err == nil { + t.Fatal("删除后 Get 应失败") + } +} + +func TestLocalGetDeleteRejectOutsideRef(t *testing.T) { + l := newTestLocal(t) + outside := filepath.Join(t.TempDir(), "outside.txt") + if err := os.WriteFile(outside, []byte("secret"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := l.Get(outside); err == nil { + t.Fatal("Get 目录外引用应拒绝") + } + if err := l.Delete(outside); err == nil { + t.Fatal("Delete 目录外引用应拒绝") + } +}