Validate object ids in the local blobstore to block path traversal

Why: Local.Put joined the caller-supplied id straight into the store directory, and store.UpdateArtifactContent feeds the HTTP path parameter into it, so a traversal id (encoded ../ in the artifact path) reaches an arbitrary-path file write; Get/Delete took stored refs unvalidated too. The 2026-09-30 deep security scan flagged the unsanitized join as the one actionable finding after triage.
Evidence: new internal/blobstore tests cover rejection of empty/dot/dotdot/traversal/absolute/backslash/NUL ids with no residue in the store dir, a normal Put/Get/Delete roundtrip, and refusal of out-of-store refs for Get and Delete; go vet ./... and go test -count=1 ./... pass.
Impact: internal/blobstore only; plain-filename ids (the only form produced today via newID and artifact path params) keep working; no schema, API, or caller change.
Refs: Dev-side task fix/blobstore-id-hardening (gitlink pin follows after combined verification)
This commit is contained in:
2026-09-30 16:32:17 +00:00
parent fa494c3715
commit 4fd0402f75
2 changed files with 100 additions and 0 deletions
+29
View File
@@ -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
+71
View File
@@ -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 目录外引用应拒绝")
}
}