filer.backup: keep local sink writes inside the sink directory (#12310)

* localsink: skip keys that resolve outside the sink directory

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* filer: reject entry paths with dot segments

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* filer: test renames onto dot-segment targets

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* filer: keep the dot-segment rename test inside one bucket scope

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* localsink: resolve keys through an os.Root on the sink directory

The lexical check alone lets a symlinked directory that already exists
under the sink directory carry writes and deletes outside it. Create,
rename and delete now go through an os.Root opened on the sink
directory, so a symlink can only resolve inside it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* localsink: follow absolute symlinks that resolve inside the sink directory

os.Root rejects every absolute symlink target, so a backup through an
absolute link that points back into the sink failed, and a delete through
it left the file behind. Resolve the parent of the key first and use the
resolved path when it stays under the sink directory. Operations still go
through os.Root, so a link swapped after the check cannot escape.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chris LuandClaude Opus 5.5 authored and GitHub committed 2026-11-01 18:33:07 -08:00
1 parent 88b76b5890
commit 2ea63bad48
7 files changed
+326 -16

No files matched your search

+35
View File
@@ -1,11 +1,14 @@
package command
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/replication/sink"
"github.com/seaweedfs/seaweedfs/weed/replication/sink/localsink"
"github.com/seaweedfs/seaweedfs/weed/replication/source"
"github.com/seaweedfs/seaweedfs/weed/util"
)
@@ -343,3 +346,35 @@ func TestGenProcessFunctionRenameFromExcludedDirBecomesCreate(t *testing.T) {
t.Fatalf("unexpected delete/update calls: deletes=%v updates=%v", dataSink.deleteKeys, dataSink.updateKeys)
}
}
func TestGenProcessFunctionLocalSinkStaysInsideTarget(t *testing.T) {
root := t.TempDir()
target := filepath.Join(root, "backup")
dataSink := &localsink.LocalSink{Dir: target}
dataSink.SetSourceFiler(&source.FilerSource{})
processFn := genProcessFunction("/", target, nil, nil, nil, nil, dataSink, true, false)
for _, dir := range []string{"/safe", "/../escape"} {
err := processFn(&filer_pb.SubscribeMetadataResponse{
Directory: dir,
EventNotification: &filer_pb.EventNotification{
NewParentPath: dir,
NewEntry: &filer_pb.Entry{
Name: "marker.txt",
Content: []byte("marker"),
Attributes: &filer_pb.FuseAttributes{FileMode: 0644, FileSize: 6},
},
},
})
if err != nil {
t.Fatalf("processFn %s: %v", dir, err)
}
}
if _, err := os.Stat(filepath.Join(target, "safe", "marker.txt")); err != nil {
t.Fatalf("inside file missing: %v", err)
}
if _, err := os.Stat(filepath.Join(root, "escape")); !os.IsNotExist(err) {
t.Fatalf("local sink wrote outside %s: %v", target, err)
}
}