webdav: delete a folder with its contents (#11811)

* webdav: delete a folder with its contents

* webdav: guard the recursive delete against stale and confined paths

- propagate stat lookup errors instead of returning an error-bearing
  FileInfo: OpenFile and DELETE would otherwise take the recursive
  removeAll on a path whose type was never confirmed, wiping a
  directory on a transient filer error
- refuse wrappedFs.Rename from or to the served root, matching the
  RemoveAll guard and webdav.Dir
- normalize trailing slashes in removeAll so the delete names the
  entry itself, and refuse the root for every caller

* webdav: keep file-only deletes shallow and refuse ancestor destinations

- removeAll used by OpenFile and failed-upload cleanup now deletes
  non-recursively, so a folder swapped in after the stat check is not
  emptied by a pending file delete
- MOVE and COPY whose destination contains the source are refused
  before the handler removes the destination, which would otherwise
  take the source down with it

* webdav: also refuse a destination inside the source

a MOVE or COPY into its own subtree recurses into the destination it
just created; refuse it with the same guard instead of burning the
recursion budget for a 500

* webdav: allow a shallow copy into the source subtree

a COPY with Depth: 0 creates only the destination collection and never
reads children, so it is safe inside the source; only deep copies and
moves are refused there

* webdav: refuse a copy/move whose source is unreadable

* webdav: cover the missing-source refusal in the guard test

* webdav: 500 on a source lookup failure, 404 only when absent

---------

Co-authored-by: Chris Lu <chris.lu@gmail.com>
Co-authored-by: Chris Lu <chrislusf@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-10-18 12:57:10 -07:00
1 parent 8cb42cd69f
commit 863efb3d35
3 files changed
+263 -32

No files matched your search

+68 -27
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"os"
"path"
"strings"
@@ -74,9 +75,12 @@ func NewWebDavServer(option *WebDavOption) (ws *WebDavServer, err error) {
option: option,
grpcDialOption: security.LoadClientTLS(util.GetViper(), "grpc.filer"),
Handler: listedEntriesHandler{
next: &webdav.Handler{
FileSystem: fs,
LockSystem: webdav.NewMemLS(),
next: copyMoveGuardHandler{
fs: fs,
next: &webdav.Handler{
FileSystem: fs,
LockSystem: webdav.NewMemLS(),
},
},
},
}
@@ -84,6 +88,40 @@ func NewWebDavServer(option *WebDavOption) (ws *WebDavServer, err error) {
return ws, nil
}
// an overwriting MOVE or COPY removes the destination before reading the
// source, so a destination that contains the source removes it too; a
// destination under the source copies itself unless the copy is shallow
type copyMoveGuardHandler struct {
fs webdav.FileSystem
next http.Handler
}
func (h copyMoveGuardHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method == "MOVE" || r.Method == "COPY" {
if u, err := url.Parse(r.Header.Get("Destination")); err == nil {
src := strings.Trim(path.Clean(r.URL.Path), "/")
dst := strings.Trim(path.Clean(u.Path), "/")
above := dst == "" || src == dst || strings.HasPrefix(src, dst+"/")
below := src == "" || strings.HasPrefix(dst, src+"/")
if above || below && (r.Method == "MOVE" || r.Header.Get("Depth") != "0") {
w.WriteHeader(http.StatusForbidden)
return
}
}
// a source that cannot be read makes the operation fail after the
// destination is already gone — reject it before that
if _, err := h.fs.Stat(r.Context(), r.URL.Path); err != nil {
if os.IsNotExist(err) {
w.WriteHeader(http.StatusNotFound)
} else {
w.WriteHeader(http.StatusInternalServerError)
}
return
}
}
h.next.ServeHTTP(w, r)
}
// adapted from https://github.com/mattn/davfs/blob/master/plugin/mysql/mysql.go
type WebDavFileSystem struct {
@@ -101,7 +139,6 @@ type FileInfo struct {
modifiedTime time.Time
etag string
isDirectory bool
err error
}
func (fi *FileInfo) Name() string { return fi.name }
@@ -112,12 +149,8 @@ func (fi *FileInfo) IsDir() bool { return fi.isDirectory }
func (fi *FileInfo) Sys() interface{} { return nil }
func (fi *FileInfo) ETag(ctx context.Context) (string, error) {
if fi.err != nil {
return "", fi.err
}
if fi.etag == "" {
// nothing hashed this entry; let webdav derive one rather than
// publish an empty DAV:getetag, which is not a valid entity-tag
// an empty DAV:getetag is not a valid entity-tag; let webdav derive one
return "", webdav.ErrNotImplemented
}
return fi.etag, nil
@@ -203,6 +236,9 @@ func (fs *WebDavFileSystem) Mkdir(ctx context.Context, fullDirPath string, perm
if err == nil {
return os.ErrExist
}
if err != os.ErrNotExist {
return err
}
return fs.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
dir, name := util.NormalizePath(fullDirPath).DirAndName()
@@ -244,12 +280,14 @@ func (fs *WebDavFileSystem) OpenFile(ctx context.Context, fullFilePath string, f
if strings.HasSuffix(fullFilePath, "/") {
return nil, os.ErrInvalid
}
_, err = fs.stat(ctx, fullFilePath)
fi, err := fs.stat(ctx, fullFilePath)
if err == nil {
if flag&os.O_EXCL != 0 {
if flag&os.O_EXCL != 0 || fi.IsDir() {
return nil, os.ErrExist
}
fs.removeAll(ctx, fullFilePath)
fs.removeAll(ctx, fullFilePath, false)
} else if err != os.ErrNotExist {
return nil, err
}
dir, name := util.FullPath(fullFilePath).DirAndName()
@@ -307,15 +345,19 @@ func (fs *WebDavFileSystem) OpenFile(ctx context.Context, fullFilePath string, f
}
func (fs *WebDavFileSystem) removeAll(ctx context.Context, fullFilePath string) error {
func (fs *WebDavFileSystem) removeAll(ctx context.Context, fullFilePath string, recursive bool) error {
var err error
if fullFilePath, err = clearName(fullFilePath); err != nil {
return err
}
dir, name := util.FullPath(fullFilePath).DirAndName()
fullpath := util.NormalizePath(fullFilePath)
if fullpath == "/" {
return os.ErrInvalid
}
dir, name := fullpath.DirAndName()
return filer_pb.Remove(context.Background(), fs, dir, name, true, false, false, false, []int32{fs.signature})
return filer_pb.Remove(context.Background(), fs, dir, name, true, recursive, false, false, []int32{fs.signature})
}
@@ -323,7 +365,7 @@ func (fs *WebDavFileSystem) RemoveAll(ctx context.Context, name string) error {
glog.V(2).Infof("WebDavFileSystem.RemoveAll %v", name)
return fs.removeAll(ctx, name)
return fs.removeAll(ctx, name, true)
}
func (fs *WebDavFileSystem) Rename(ctx context.Context, oldName, newName string) error {
@@ -338,23 +380,22 @@ func (fs *WebDavFileSystem) Rename(ctx context.Context, oldName, newName string)
return err
}
of, err := fs.stat(ctx, oldName)
if err != nil {
if _, err = fs.stat(ctx, oldName); err != nil {
return os.ErrExist
}
if of.IsDir() {
if strings.HasSuffix(oldName, "/") {
oldName = strings.TrimRight(oldName, "/")
}
if strings.HasSuffix(newName, "/") {
newName = strings.TrimRight(newName, "/")
}
oldName = string(util.NormalizePath(oldName))
newName = string(util.NormalizePath(newName))
if oldName == "/" || newName == "/" {
return os.ErrInvalid
}
_, err = fs.stat(ctx, newName)
if err == nil {
return os.ErrExist
}
if err != os.ErrNotExist {
return err
}
oldDir, oldBaseName := util.FullPath(oldName).DirAndName()
newDir, newBaseName := util.FullPath(newName).DirAndName()
@@ -395,7 +436,7 @@ func (fs *WebDavFileSystem) stat(ctx context.Context, fullFilePath string) (os.F
if err == filer_pb.ErrNotFound {
return nil, os.ErrNotExist
}
return &FileInfo{err: err}, nil
return nil, err
}
if entry == nil {
return nil, os.ErrNotExist
@@ -481,7 +522,7 @@ func (f *WebDavFile) Write(buf []byte) (int, error) {
if flushErr != nil {
if f.entry.Attributes.Mtime == 0 {
if err := f.fs.removeAll(ctx, f.name); err != nil {
if err := f.fs.removeAll(ctx, f.name, false); err != nil {
glog.Errorf("bufWriter.Flush remove file error: %+v", f.name)
}
}