test: harden multipart upload compatibility

This commit is contained in:
OpenClaw Agent
2026-09-22 00:17:25 +08:00
parent 05857b91a4
commit 0bdc206d6c
2 changed files with 184 additions and 2 deletions
+28 -2
View File
@@ -531,6 +531,9 @@ func (s *Server) upload(w http.ResponseWriter, r *http.Request) {
writeJSONError(w, http.StatusForbidden, "has no privilege") writeJSONError(w, http.StatusForbidden, "has no privilege")
return return
} }
// Preflight the complete multipart batch before creating anything.
// This prevents duplicate/bad entries from leaving a partial upload.
seen := make(map[string]struct{})
var saved []string var saved []string
for _, hs := range r.MultipartForm.File { for _, hs := range r.MultipartForm.File {
for _, h := range hs { for _, h := range hs {
@@ -540,6 +543,11 @@ func (s *Server) upload(w http.ResponseWriter, r *http.Request) {
return return
} }
target := path.Join(dir, name) target := path.Join(dir, name)
if _, ok := seen[target]; ok {
writeJSONError(w, http.StatusBadRequest, "invalid parameter")
return
}
seen[target] = struct{}{}
if _, e := s.Store.Stat(target); e == nil { if _, e := s.Store.Stat(target); e == nil {
writeJSONError(w, http.StatusBadRequest, "invalid parameter") writeJSONError(w, http.StatusBadRequest, "invalid parameter")
return return
@@ -547,6 +555,12 @@ func (s *Server) upload(w http.ResponseWriter, r *http.Request) {
http.Error(w, e.Error(), 500) http.Error(w, e.Error(), 500)
return return
} }
// Writing happens only after every part has passed validation.
}
}
for _, hs := range r.MultipartForm.File {
for _, h := range hs {
target := path.Join(dir, filepath.ToSlash(h.Filename))
if e := savePart(s.Store, target, h); e != nil { if e := savePart(s.Store, target, h); e != nil {
http.Error(w, e.Error(), 500) http.Error(w, e.Error(), 500)
return return
@@ -590,7 +604,9 @@ func (s *Server) upload2(w http.ResponseWriter, r *http.Request) {
http.Error(w, err.Error(), 400) http.Error(w, err.Error(), 400)
return return
} }
var saved []string // Preflight the complete multipart batch before creating anything.
// This prevents duplicate/bad entries from leaving a partial upload.
seen := make(map[string]struct{})
for _, hs := range r.MultipartForm.File { for _, hs := range r.MultipartForm.File {
for _, h := range hs { for _, h := range hs {
name := filepath.ToSlash(h.Filename) name := filepath.ToSlash(h.Filename)
@@ -600,6 +616,11 @@ func (s *Server) upload2(w http.ResponseWriter, r *http.Request) {
return return
} }
target := path.Join(dir, name) target := path.Join(dir, name)
if _, ok := seen[target]; ok {
writeJSONError(w, http.StatusBadRequest, "invalid parameter")
return
}
seen[target] = struct{}{}
if _, e := s.Store.Stat(target); e == nil { if _, e := s.Store.Stat(target); e == nil {
writeJSONError(w, http.StatusBadRequest, "invalid parameter") writeJSONError(w, http.StatusBadRequest, "invalid parameter")
return return
@@ -607,11 +628,16 @@ func (s *Server) upload2(w http.ResponseWriter, r *http.Request) {
http.Error(w, e.Error(), 500) http.Error(w, e.Error(), 500)
return return
} }
}
}
for _, hs := range r.MultipartForm.File {
for _, h := range hs {
name := strings.TrimPrefix(filepath.ToSlash(h.Filename), "/")
target := path.Join(dir, name)
if err := savePart(s.Store, target, h); err != nil { if err := savePart(s.Store, target, h); err != nil {
http.Error(w, err.Error(), 500) http.Error(w, err.Error(), 500)
return return
} }
saved = append(saved, path.Join(dir, name))
} }
} }
writeCreated(w) writeCreated(w)
+156
View File
@@ -0,0 +1,156 @@
package api
import (
"bytes"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"chfs-ng/internal/auth"
"chfs-ng/internal/storage"
)
func uploadCompatServer(t *testing.T) (*Server, string) {
t.Helper()
root := t.TempDir()
if err := os.MkdirAll(filepath.Join(root, "dir"), 0o755); err != nil {
t.Fatal(err)
}
return &Server{Store: storage.Local{Root: root}, Auth: &auth.Manager{Accounts: map[string]*auth.Account{}, Guest: &auth.Account{Mask: auth.Read | auth.Write | auth.Delete}}, RemoveMode: 3}, root
}
func makeUploadRequest(t *testing.T, method, target string, fieldName, fieldValue, fileName, content string) (*httptest.ResponseRecorder, *http.Request) {
t.Helper()
var b bytes.Buffer
mw := multipart.NewWriter(&b)
if fieldName != "" {
if err := mw.WriteField(fieldName, fieldValue); err != nil {
t.Fatal(err)
}
}
if fileName != "" {
fw, err := mw.CreateFormFile("file", fileName)
if err != nil {
t.Fatal(err)
}
if _, err := fw.Write([]byte(content)); err != nil {
t.Fatal(err)
}
}
if err := mw.Close(); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(method, target, &b)
req.Header.Set("Content-Type", mw.FormDataContentType())
return httptest.NewRecorder(), req
}
func TestUploadNormalizesClientFilenamesInsideTargetFolder(t *testing.T) {
s, root := uploadCompatServer(t)
for _, tc := range []struct{ input, output string }{{"../escape1.txt", "escape1.txt"}, {"/absolute3.txt", "absolute3.txt"}} {
rr, req := makeUploadRequest(t, http.MethodPost, "/chfs/upload?folder=dir", "", "", tc.input, "x")
s.Handler().ServeHTTP(rr, req)
if rr.Code != http.StatusCreated {
t.Errorf("upload %q: status=%d", tc.input, rr.Code)
}
if _, err := os.Stat(filepath.Join(root, tc.output)); !os.IsNotExist(err) {
t.Fatalf("filename escaped target folder: %v", err)
}
if _, err := os.Stat(filepath.Join(root, "dir", tc.output)); err != nil {
t.Fatalf("normalized upload %s missing: %v", tc.output, err)
}
}
}
func TestUpload2UsesQueryFolderAndRejectsTraversal(t *testing.T) {
s, root := uploadCompatServer(t)
rr, req := makeUploadRequest(t, http.MethodPost, "/chfs/upload2?folder=dir", "", "", "nested.txt", "hello")
s.Handler().ServeHTTP(rr, req)
if rr.Code != http.StatusCreated {
t.Fatalf("upload2 status=%d body=%s", rr.Code, rr.Body.String())
}
got, err := os.ReadFile(filepath.Join(root, "dir", "nested.txt"))
if err != nil {
t.Fatal(err)
}
if string(got) != "hello" {
t.Fatalf("content=%q", got)
}
for _, folder := range []string{"../", "..%2F", "..\\\\"} {
rr, req = makeUploadRequest(t, http.MethodPost, "/chfs/upload2?folder="+folder, "", "", "bad.txt", "x")
s.Handler().ServeHTTP(rr, req)
if rr.Code != http.StatusBadRequest {
t.Errorf("upload2 folder %q: status=%d", folder, rr.Code)
}
}
}
func TestUploadRejectsDuplicateWithoutOverwriting(t *testing.T) {
s, root := uploadCompatServer(t)
target := filepath.Join(root, "dir", "same.txt")
if err := os.WriteFile(target, []byte("original"), 0o644); err != nil {
t.Fatal(err)
}
rr, req := makeUploadRequest(t, http.MethodPost, "/chfs/upload", "folder", "dir", "same.txt", "replacement")
s.Handler().ServeHTTP(rr, req)
if rr.Code != http.StatusBadRequest {
t.Fatalf("status=%d", rr.Code)
}
got, err := os.ReadFile(target)
if err != nil {
t.Fatal(err)
}
if string(got) != "original" {
t.Fatalf("duplicate upload overwrote file: %q", got)
}
}
func TestUpload2RejectsDuplicateWithoutOverwriting(t *testing.T) {
s, root := uploadCompatServer(t)
target := filepath.Join(root, "dir", "same.txt")
if err := os.WriteFile(target, []byte("original"), 0o644); err != nil {
t.Fatal(err)
}
rr, req := makeUploadRequest(t, http.MethodPost, "/chfs/upload2?folder=dir", "", "", "same.txt", "replacement")
s.Handler().ServeHTTP(rr, req)
if rr.Code != http.StatusBadRequest {
t.Fatalf("status=%d", rr.Code)
}
got, err := os.ReadFile(target)
if err != nil {
t.Fatal(err)
}
if string(got) != "original" {
t.Fatalf("duplicate upload2 overwrote file: %q", got)
}
}
func TestUpload2BatchWritesAllFiles(t *testing.T) {
s, root := uploadCompatServer(t)
var b bytes.Buffer
mw := multipart.NewWriter(&b)
for _, name := range []string{"first.txt", "second.txt"} {
fw, err := mw.CreateFormFile("file", name)
if err != nil {
t.Fatal(err)
}
_, _ = fw.Write([]byte(name))
}
if err := mw.Close(); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "/chfs/upload2?folder=dir", &b)
req.Header.Set("Content-Type", mw.FormDataContentType())
rr := httptest.NewRecorder()
s.Handler().ServeHTTP(rr, req)
if rr.Code != http.StatusCreated {
t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
}
for _, name := range []string{"first.txt", "second.txt"} {
if _, err := os.Stat(filepath.Join(root, "dir", name)); err != nil {
t.Fatalf("%s missing: %v", name, err)
}
}
}