Validate object ids in the local blobstore to block path traversal #22
@@ -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
|
||||
|
||||
@@ -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 目录外引用应拒绝")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user