From f3d80e939e7483ccb3e2eb559b09e0551bbb9188 Mon Sep 17 00:00:00 2001 From: OpenClaw Agent Date: Tue, 22 Sep 2026 00:30:39 +0800 Subject: [PATCH] fix: harden exclusive file creation and webdav writes --- chfs-ng/internal/api/server.go | 215 +++++---------------- chfs-ng/internal/storage/multipath.go | 8 + chfs-ng/internal/storage/multipath_test.go | 41 ++++ chfs-ng/internal/storage/storage.go | 15 ++ 4 files changed, 116 insertions(+), 163 deletions(-) diff --git a/chfs-ng/internal/api/server.go b/chfs-ng/internal/api/server.go index b50d47e..05c1115 100644 --- a/chfs-ng/internal/api/server.go +++ b/chfs-ng/internal/api/server.go @@ -478,13 +478,20 @@ func (s *Server) newtext(w http.ResponseWriter, r *http.Request) { writeJSONError(w, http.StatusForbidden, "has no privilege") return } - f, e := s.Store.Create(p) + f, e := createExclusive(s.Store, p) if e != nil { + if os.IsExist(e) { + writeJSONError(w, http.StatusBadRequest, "invalid parameter") + return + } http.Error(w, e.Error(), 500) return } defer f.Close() - io.WriteString(f, r.FormValue("text")) + if _, e = io.WriteString(f, r.FormValue("text")); e != nil { + http.Error(w, e.Error(), 500) + return + } s.logOp(r, fmt.Sprintf("create a text file:'%s'", p)) writeCreated(w) } @@ -573,19 +580,42 @@ func (s *Server) upload(w http.ResponseWriter, r *http.Request) { } writeCreated(w) } +func createExclusive(st storage.Storage, target string) (*os.File, error) { + if x, ok := st.(storage.ExclusiveCreator); ok { + return x.CreateExclusive(target) + } + if _, err := st.Stat(target); err == nil { + return nil, os.ErrExist + } else if !os.IsNotExist(err) { + return nil, err + } + return st.Create(target) +} + func savePart(st storage.Storage, target string, h *multipart.FileHeader) error { in, e := h.Open() if e != nil { return e } defer in.Close() - f, e := st.Create(target) + f, e := createExclusive(st, target) if e != nil { return e } - defer f.Close() - _, e = io.Copy(f, in) - return e + copyErr := func() error { + _, err := io.Copy(f, in) + return err + }() + closeErr := f.Close() + if copyErr != nil { + _ = st.Remove(target) + return copyErr + } + if closeErr != nil { + _ = st.Remove(target) + return closeErr + } + return nil } func (s *Server) upload2(w http.ResponseWriter, r *http.Request) { @@ -1079,17 +1109,32 @@ func (f *webdavStorageFS) Create(ctx context.Context, name string, body io.ReadC if statErr != nil && !os.IsNotExist(statErr) { return nil, false, statErr } - file, err := f.store.Create(p) + exclusive := opts != nil && opts.IfNoneMatch.IsWildcard() + var file *os.File + if exclusive { + file, err = createExclusive(f.store, p) + } else { + file, err = f.store.Create(p) + } if err != nil { + if exclusive && os.IsExist(err) { + return nil, false, webdav.NewHTTPError(http.StatusPreconditionFailed, fmt.Errorf("destination exists")) + } return nil, false, err } _, copyErr := io.Copy(file, body) closeErr := file.Close() body.Close() if copyErr != nil { + if exclusive { + _ = f.store.Remove(p) + } return nil, false, copyErr } if closeErr != nil { + if exclusive { + _ = f.store.Remove(p) + } return nil, false, closeErr } fi, err := f.info(p) @@ -1272,162 +1317,6 @@ func (f *webdavStorageFS) Move(ctx context.Context, name, dest string, opts *web return true, nil } -type webdavMultiFS struct { - roots map[string]webdav.FileSystem - names []string -} - -func newWebDAVMultiFS(m storage.Multi) *webdavMultiFS { - fs := &webdavMultiFS{roots: make(map[string]webdav.FileSystem), names: append([]string(nil), m.Names...)} - for i, name := range m.Names { - if i < len(m.Roots) { - fs.roots[name] = webdav.LocalFileSystem(m.Roots[i].Root) - } - } - return fs -} -func splitDAVPath(name string) (string, string) { - p := strings.Trim(strings.ReplaceAll(name, "\\\\", "/"), "/") - p = strings.TrimPrefix(p, "webdav/") - p = strings.TrimPrefix(p, "webdav") - if p == "" { - return "", "" - } - parts := strings.SplitN(p, "/", 2) - if len(parts) == 1 { - return parts[0], "" - } - return parts[0], parts[1] -} -func (f *webdavMultiFS) root(name string) (webdav.FileSystem, string, error) { - r, rel := splitDAVPath(name) - if r == "" { - return nil, "", nil - } - fs, ok := f.roots[r] - if !ok { - return nil, "", webdav.NewHTTPError(http.StatusNotFound, fmt.Errorf("root not found")) - } - return fs, rel, nil -} -func (f *webdavMultiFS) Open(ctx context.Context, name string) (io.ReadCloser, error) { - fs, rel, err := f.root(name) - if err != nil { - return nil, err - } - if fs == nil { - return nil, webdav.NewHTTPError(http.StatusForbidden, fmt.Errorf("virtual root is not a file")) - } - return fs.Open(ctx, "/"+rel) -} -func (f *webdavMultiFS) Stat(ctx context.Context, name string) (*webdav.FileInfo, error) { - if strings.Trim(strings.ReplaceAll(name, "\\\\", "/"), "/") == "" { - return &webdav.FileInfo{Path: "/", IsDir: true}, nil - } - fs, rel, err := f.root(name) - if err != nil { - return nil, err - } - if rel == "" { - return &webdav.FileInfo{Path: "/" + strings.Trim(strings.SplitN(strings.Trim(name, "/"), "/", 2)[0], "/"), IsDir: true}, nil - } - fi, err := fs.Stat(ctx, "/"+rel) - if err != nil { - return nil, err - } - fi.Path = "/" + strings.Trim(name, "/") - return fi, nil -} -func (f *webdavMultiFS) ReadDir(ctx context.Context, name string, recursive bool) ([]webdav.FileInfo, error) { - p := strings.Trim(strings.ReplaceAll(name, "\\\\", "/"), "/") - if p == "" { - out := make([]webdav.FileInfo, 0, len(f.names)) - for _, n := range f.names { - out = append(out, webdav.FileInfo{Path: "/" + n, IsDir: true}) - } - return out, nil - } - fs, rel, err := f.root(p) - if err != nil { - return nil, err - } - items, err := fs.ReadDir(ctx, "/"+rel, recursive) - if err != nil { - return nil, err - } - root, _ := splitDAVPath(p) - for i := range items { - itemPath := strings.TrimPrefix(items[i].Path, "/") - if itemPath == "" { - itemPath = filepath.Base(items[i].Path) - } - items[i].Path = "/" + path.Join(root, itemPath) - } - return items, nil -} -func (f *webdavMultiFS) Create(ctx context.Context, name string, body io.ReadCloser, opts *webdav.CreateOptions) (*webdav.FileInfo, bool, error) { - fs, rel, err := f.root(name) - if err != nil { - return nil, false, err - } - if fs == nil || rel == "" { - return nil, false, webdav.NewHTTPError(http.StatusForbidden, fmt.Errorf("cannot create virtual root")) - } - fi, created, err := fs.Create(ctx, "/"+rel, body, opts) - if err == nil { - fi.Path = "/" + strings.Trim(name, "/") - } - return fi, created, err -} -func (f *webdavMultiFS) RemoveAll(ctx context.Context, name string, opts *webdav.RemoveAllOptions) error { - fs, rel, err := f.root(name) - if err != nil { - return err - } - if fs == nil || rel == "" { - return webdav.NewHTTPError(http.StatusForbidden, fmt.Errorf("cannot remove virtual root")) - } - return fs.RemoveAll(ctx, "/"+rel, opts) -} -func (f *webdavMultiFS) Mkdir(ctx context.Context, name string) error { - fs, rel, err := f.root(name) - if err != nil { - return err - } - if fs == nil || rel == "" { - return webdav.NewHTTPError(http.StatusForbidden, fmt.Errorf("cannot create virtual root")) - } - return fs.Mkdir(ctx, "/"+rel) -} -func (f *webdavMultiFS) Copy(ctx context.Context, name, dest string, opts *webdav.CopyOptions) (bool, error) { - fs, rel, err := f.root(name) - if err != nil { - return false, err - } - dfs, drel, err := f.root(dest) - if err != nil { - return false, err - } - if fs == nil || dfs == nil || fs != dfs { - return false, webdav.NewHTTPError(http.StatusForbidden, fmt.Errorf("cross-root copy is not supported")) - } - return fs.Copy(ctx, "/"+rel, "/"+drel, opts) -} -func (f *webdavMultiFS) Move(ctx context.Context, name, dest string, opts *webdav.MoveOptions) (bool, error) { - fs, rel, err := f.root(name) - if err != nil { - return false, err - } - dfs, drel, err := f.root(dest) - if err != nil { - return false, err - } - if fs == nil || dfs == nil || fs != dfs { - return false, webdav.NewHTTPError(http.StatusForbidden, fmt.Errorf("cross-root move is not supported")) - } - return fs.Move(ctx, "/"+rel, "/"+drel, opts) -} - type webdavAuthFS struct { inner webdav.FileSystem account *auth.Account diff --git a/chfs-ng/internal/storage/multipath.go b/chfs-ng/internal/storage/multipath.go index 362723a..6c6dce0 100644 --- a/chfs-ng/internal/storage/multipath.go +++ b/chfs-ng/internal/storage/multipath.go @@ -107,6 +107,14 @@ func (m Multi) Create(p string) (*os.File, error) { } return r.Create(rel) } + +func (m Multi) CreateExclusive(p string) (*os.File, error) { + r, rel, ok := m.find(p) + if !ok { + return nil, os.ErrNotExist + } + return r.CreateExclusive(rel) +} func (m Multi) Remove(p string) error { r, rel, ok := m.find(p) if !ok || rel == "" { diff --git a/chfs-ng/internal/storage/multipath_test.go b/chfs-ng/internal/storage/multipath_test.go index 684b608..8295f58 100644 --- a/chfs-ng/internal/storage/multipath_test.go +++ b/chfs-ng/internal/storage/multipath_test.go @@ -46,3 +46,44 @@ func TestMultiRemoveCannotRemoveVirtualRoot(t *testing.T) { t.Fatal("expected virtual root removal to fail") } } + +func TestLocalCreateExclusiveRejectsExistingFile(t *testing.T) { + root := t.TempDir() + st := Local{Root: root} + if err := os.WriteFile(filepath.Join(root, "x.txt"), []byte("old"), 0o644); err != nil { + t.Fatal(err) + } + f, err := st.CreateExclusive("x.txt") + if !os.IsExist(err) || f != nil { + t.Fatalf("expected EEXIST, file=%v err=%v", f, err) + } + got, err := os.ReadFile(filepath.Join(root, "x.txt")) + if err != nil { + t.Fatal(err) + } + if string(got) != "old" { + t.Fatalf("existing file changed: %q", got) + } +} + +func TestLocalCreateExclusiveCreatesMissingFile(t *testing.T) { + st := Local{Root: t.TempDir()} + f, err := st.CreateExclusive("nested/x.txt") + if err != nil { + t.Fatal(err) + } + if _, err := f.WriteString("new"); err != nil { + f.Close() + t.Fatal(err) + } + if err := f.Close(); err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(filepath.Join(st.Root, "nested", "x.txt")) + if err != nil { + t.Fatal(err) + } + if string(got) != "new" { + t.Fatalf("unexpected content: %q", got) + } +} diff --git a/chfs-ng/internal/storage/storage.go b/chfs-ng/internal/storage/storage.go index 2a845a2..51975cf 100644 --- a/chfs-ng/internal/storage/storage.go +++ b/chfs-ng/internal/storage/storage.go @@ -30,6 +30,10 @@ type Storage interface { List(path string) ([]os.FileInfo, error) } +type ExclusiveCreator interface { + CreateExclusive(path string) (*os.File, error) +} + type Local struct{ Root string } func (s Local) real(path string) (string, error) { @@ -95,6 +99,17 @@ func (s Local) Create(path string) (*os.File, error) { } return os.Create(p) } + +func (s Local) CreateExclusive(path string) (*os.File, error) { + p, e := s.real(path) + if e != nil { + return nil, e + } + if e = os.MkdirAll(filepath.Dir(p), 0755); e != nil { + return nil, e + } + return os.OpenFile(p, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644) +} func (s Local) Remove(path string) error { p, e := s.real(path) if e == nil {