Files
rclone/cmd/archive/extract/extract_test.go
T
Nick Craig-Wood d11efe0d58 archive extract: fix path traversal letting archives escape the destination CVE-2026-59732
Archive entry names are attacker controlled. `rclone archive extract` stripped
only a leading `./` and then joined the entry name onto the destination
directory with `path.Join`, which collapses `..` segments. An entry such as
`../escaped.txt` extracted into `:s3:bucket/safe/prefix` therefore resolved to
`bucket/safe/escaped.txt`, outside the selected `prefix` directory - a path
traversal ("Zip Slip") attack that could create or overwrite sibling objects on
any destination remote.

Entry names are now validated before use: a leading `./` is still stripped (tar
archives created with `tar -czf archive.tar.gz .` rely on this), but any entry
with a `..` path component is rejected. Both `/` and `\` are treated as
separators when looking for `..`, as the local backend treats `\` as a path
separator on Windows.

Fixes: GHSA-4vr5-p2gc-h23p
2026-07-08 16:05:38 +01:00

103 lines
2.0 KiB
Go

//go:build !plan9
package extract
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestDestPath(t *testing.T) {
tests := []struct {
name string
input string
dstDir string
expected string
wantErr bool
}{
{
name: "strip leading dot-slash from file",
input: "./file.txt",
expected: "file.txt",
},
{
name: "strip leading dot-slash from nested path",
input: "./subdir/file.txt",
expected: "subdir/file.txt",
},
{
name: "no prefix unchanged",
input: "file.txt",
expected: "file.txt",
},
{
name: "nested path unchanged",
input: "dir/file.txt",
expected: "dir/file.txt",
},
{
name: "joined onto destination directory",
input: "file.txt",
dstDir: "safe/prefix",
expected: "safe/prefix/file.txt",
},
{
name: "archive root entry skipped",
input: "./",
expected: "",
},
{
name: "only single leading dot-slash stripped",
input: "././file.txt",
expected: "./file.txt",
},
{
name: "leading dot-dot rejected",
input: "../etc/passwd",
wantErr: true,
},
{
name: "leading dot-dot rejected with destination",
input: "../escaped.txt",
dstDir: "safe/prefix",
wantErr: true,
},
{
name: "interior dot-dot rejected",
input: "dir/../../escaped.txt",
dstDir: "safe/prefix",
wantErr: true,
},
{
name: "trailing dot-dot rejected",
input: "dir/..",
wantErr: true,
},
{
name: "backslash dot-dot rejected",
input: `..\escaped.txt`,
wantErr: true,
},
{
name: "nested backslash dot-dot rejected",
input: `dir\..\..\escaped.txt`,
dstDir: "safe/prefix",
wantErr: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, err := destPath(tc.input, tc.dstDir)
if tc.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, tc.expected, got)
})
}
}