Files
rclone/fs/list/list_test.go
T
Nick Craig-Wood 935197b062 fs: confine directory listing entries that escape the root GHSA-3vxh-3pcx-9m8q GHSA-38xv-hf3p-h7mq CVE-PENDING
The rclone core does not sanitise ".." in an object's Remote(). Such a name can
arrive from a malicious or buggy backend - an object store permits keys
containing ".." or a leading "/" - and, if acted on, lets a listing or transfer
escape the configured root. A source object named "../../other/x" is copied to
"other/x" outside the destination root, and a crafted listing name surfaces
outside the directory being listed.

Add list.RemoteEscapesRoot, which reports whether a Remote climbs above the
root when joined onto it, and list.RemoveEscaping, which drops and logs such
entries.

Apply RemoveEscaping unconditionally - independent of the include/exclude
filters - at the three per-entry filtering points every listing passes through:
filterDir, walk.listR and walk.walkRDirTree (recursive ListR).
operations.StatJSON calls List and NewObject directly, bypassing those, so it
rejects an escaping remote up front.

This confines every backend at once, so no per-backend change is needed.
2026-09-04 19:00:22 +01:00

160 lines
5.2 KiB
Go

package list
import (
"context"
"testing"
"time"
"github.com/rclone/rclone/fs"
"github.com/rclone/rclone/fstest/mockdir"
"github.com/rclone/rclone/fstest/mockobject"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// NB integration tests for DirSorted are in
// fs/operations/listdirsorted_test.go
func TestRemoteEscapesRoot(t *testing.T) {
for _, test := range []struct {
in string
escape bool
}{
// non-escaping
{"", false},
{".", false},
{"a", false},
{"a/b", false},
{"a/../b", false}, // cleans to "b" - does not climb
{"foo/..", false}, // cleans back to root
{"..foo", false}, // not a ".." segment
{"a/..bar/c", false},
{"...", false}, // three dots is an ordinary name
{"/", false}, // absolute, but does not climb
{"/a/b", false},
{"//a", false},
// climbing with a relative prefix
{"..", true},
{"../b", true},
{"a/../../b", true}, // cleans to "../b"
{"../../etc/passwd", true},
// climbing hidden behind a leading slash - path.Clean would anchor
// these as absolute and miss them, but path.Join(root, ...) escapes
{"/..", true},
{"/../x", true},
{"//../../x", true},
{"/../../etc/passwd", true},
{"/./../x", true},
} {
assert.Equal(t, test.escape, RemoteEscapesRoot(test.in), test.in)
}
}
func TestFilterAndSortConfinement(t *testing.T) {
ok := mockobject.Object("ok.txt")
dotdot := mockobject.Object("..") // bare ".." - missed by the belongs-in-dir check
up := mockobject.Object("../escape.txt") // climbs one level
upDir := mockdir.New("../evildir") // climbing directory
deepUp := mockobject.Object("a/../../x") // cleans to "../x"
entries := fs.DirEntries{ok, dotdot, up, upDir, deepUp}
includeObject := func(ctx context.Context, o fs.Object) bool { return true }
includeDirectory := func(remote string) (bool, error) { return true, nil }
// Even with includeAll, entries that escape the root are dropped.
newEntries, err := filterAndSortDir(context.Background(), entries, true, "", includeObject, includeDirectory)
require.NoError(t, err)
assert.Equal(t, fs.DirEntries{ok}, newEntries)
}
func TestFilterAndSortIncludeAll(t *testing.T) {
da := mockdir.New("a")
oA := mockobject.Object("A")
db := mockdir.New("b")
oB := mockobject.Object("B")
dc := mockdir.New("c")
oC := mockobject.Object("C")
dd := mockdir.New("d")
oD := mockobject.Object("D")
entries := fs.DirEntries{da, oA, db, oB, dc, oC, dd, oD}
includeObject := func(ctx context.Context, o fs.Object) bool {
return o != oB
}
includeDirectory := func(remote string) (bool, error) {
return remote != "c", nil
}
// no filter
newEntries, err := filterAndSortDir(context.Background(), entries, true, "", includeObject, includeDirectory)
require.NoError(t, err)
assert.Equal(t,
newEntries,
fs.DirEntries{oA, oB, oC, oD, da, db, dc, dd},
)
// filter
newEntries, err = filterAndSortDir(context.Background(), entries, false, "", includeObject, includeDirectory)
require.NoError(t, err)
assert.Equal(t,
newEntries,
fs.DirEntries{oA, oC, oD, da, db, dd},
)
}
func TestFilterAndSortCheckDir(t *testing.T) {
// Check the different kinds of error when listing "dir"
da := mockdir.New("dir")
da2 := mockdir.New("dir/") // double slash dir - allowed for bucket based remotes
oA := mockobject.Object("diR/a")
db := mockdir.New("dir/b")
oB := mockobject.Object("dir/B/sub")
dc := mockdir.New("dir/c")
oC := mockobject.Object("dir/C")
dd := mockdir.New("dir/d")
oD := mockobject.Object("dir/D")
entries := fs.DirEntries{da, da2, oA, db, oB, dc, oC, dd, oD}
newEntries, err := filterAndSortDir(context.Background(), entries, true, "dir", nil, nil)
require.NoError(t, err)
assert.Equal(t,
fs.DirEntries{da2, oC, oD, db, dc, dd},
newEntries,
)
}
func TestFilterAndSortCheckDirRoot(t *testing.T) {
// Check the different kinds of error when listing the root ""
da := mockdir.New("")
da2 := mockdir.New("/") // doubleslash dir allowed on bucket based remotes
oA := mockobject.Object("A")
db := mockdir.New("b")
oB := mockobject.Object("B/sub")
dc := mockdir.New("c")
oC := mockobject.Object("C")
dd := mockdir.New("d")
oD := mockobject.Object("D")
entries := fs.DirEntries{da, da2, oA, db, oB, dc, oC, dd, oD}
newEntries, err := filterAndSortDir(context.Background(), entries, true, "", nil, nil)
require.NoError(t, err)
assert.Equal(t,
fs.DirEntries{da2, oA, oC, oD, db, dc, dd},
newEntries,
)
}
type unknownDirEntry string
func (o unknownDirEntry) Fs() fs.Info { return fs.Unknown }
func (o unknownDirEntry) String() string { return string(o) }
func (o unknownDirEntry) Remote() string { return string(o) }
func (o unknownDirEntry) ModTime(ctx context.Context) (t time.Time) { return t }
func (o unknownDirEntry) Size() int64 { return 0 }
func TestFilterAndSortUnknown(t *testing.T) {
// Check that an unknown entry produces an error
da := mockdir.New("")
oA := mockobject.Object("A")
ub := unknownDirEntry("b")
oB := mockobject.Object("B/sub")
entries := fs.DirEntries{da, oA, ub, oB}
newEntries, err := filterAndSortDir(context.Background(), entries, true, "", nil, nil)
assert.Error(t, err, "error")
assert.Nil(t, newEntries)
}