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.
This commit is contained in:
Nick Craig-Wood
2026-09-04 19:00:22 +01:00
parent 1615434cbe
commit 935197b062
6 changed files with 163 additions and 0 deletions
+9
View File
@@ -12,6 +12,7 @@ import (
"github.com/rclone/rclone/fs"
"github.com/rclone/rclone/fs/accounting"
"github.com/rclone/rclone/fs/hash"
"github.com/rclone/rclone/fs/list"
"github.com/rclone/rclone/fs/walk"
)
@@ -277,6 +278,14 @@ func StatJSON(ctx context.Context, fsrc fs.Fs, remote string, opt *ListJSONOpt)
return nil, err
}
// A remote that climbs above the Fs root can never be a valid item.
// StatJSON calls List/NewObject directly, bypassing the confinement in
// fs/list and fs/walk, so treat it as not found here rather than stat
// something outside the configured root.
if list.RemoteEscapesRoot(remote) {
return nil, nil
}
// Root is always a directory. When we have a NewDirEntry
// primitive we need to call it, but for now this will do.
if remote == "" {
+33
View File
@@ -12,6 +12,8 @@ import (
"github.com/rclone/rclone/fs/accounting"
"github.com/rclone/rclone/fs/operations"
"github.com/rclone/rclone/fstest"
"github.com/rclone/rclone/fstest/mockfs"
"github.com/rclone/rclone/fstest/mockobject"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -462,3 +464,34 @@ func TestStatJSONMemory(t *testing.T) {
assert.Nil(t, got)
})
}
// TestStatJSONConfinement checks that StatJSON never returns an item whose
// remote escapes the Fs root. StatJSON calls List/NewObject directly and so
// bypasses the confinement in fs/list and fs/walk - the escaping objects added
// below would be returned without the guard in StatJSON.
func TestStatJSONConfinement(t *testing.T) {
ctx := context.Background()
ff, err := mockfs.NewFs(ctx, "mock", "/", nil)
require.NoError(t, err)
f := ff.(*mockfs.Fs)
f.AddObject(mockobject.Object("ok"))
f.AddObject(mockobject.Object(".."))
f.AddObject(mockobject.Object("../evil"))
f.AddObject(mockobject.Object("/../slashevil"))
f.AddObject(mockobject.Object("//../../slashevil2"))
f.AddObject(mockobject.Object("/../../etc/passwd"))
// Escaping remotes are treated as not found, including the leading-slash
// variants that path.Clean would anchor as absolute and miss.
for _, remote := range []string{"..", "../evil", "/../slashevil", "//../../slashevil2", "/../../etc/passwd"} {
got, err := operations.StatJSON(ctx, f, remote, &operations.ListJSONOpt{})
require.NoError(t, err, remote)
assert.Nil(t, got, remote)
}
// A legitimate remote is still returned.
got, err := operations.StatJSON(ctx, f, "ok", &operations.ListJSONOpt{})
require.NoError(t, err)
require.NotNil(t, got)
assert.Equal(t, "ok", got.Path)
}