Skip to content

Commit 509a359

Browse files
committed
merge #47 into cyphar/filepath-securejoin:main
Aleksa Sarai (1): join: loosen cleanliness requirements for SecureJoin root LGTMs: kolyshkin cyphar
2 parents 54460df + fbaef26 commit 509a359

File tree

4 files changed

+142
-15
lines changed

4 files changed

+142
-15
lines changed

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,14 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
66

77
## [Unreleased] ##
88

9+
### Fixed ###
10+
- The restrictions added for `root` paths passed to `SecureJoin` in 0.4.0 was
11+
found to be too strict and caused some regressions when folks tried to
12+
update, so this restriction has been relaxed to only return an error if the
13+
path contains a `..` component. We still recommend users use `filepath.Clean`
14+
(and even `filepath.EvalSymlinks`) on the `root` path they are using, but at
15+
least you will no longer be punished for "trivial" unclean paths.
16+
917
## [0.4.0] - 2025-01-13 ##
1018

1119
### Breaking ####

join.go

Lines changed: 34 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,30 @@ func IsNotExist(err error) bool {
2424
return errors.Is(err, os.ErrNotExist) || errors.Is(err, syscall.ENOTDIR) || errors.Is(err, syscall.ENOENT)
2525
}
2626

27-
// errUncleanRoot is returned if the user provides SecureJoinVFS with a path
28-
// that is not filepath.Clean'd.
29-
var errUncleanRoot = errors.New("root path provided to SecureJoin was not filepath.Clean")
27+
// errUnsafeRoot is returned if the user provides SecureJoinVFS with a path
28+
// that contains ".." components.
29+
var errUnsafeRoot = errors.New("root path provided to SecureJoin contains '..' components")
30+
31+
// stripVolume just gets rid of the Windows volume included in a path. Based on
32+
// some godbolt tests, the Go compiler is smart enough to make this a no-op on
33+
// Linux.
34+
func stripVolume(path string) string {
35+
return path[len(filepath.VolumeName(path)):]
36+
}
37+
38+
// hasDotDot checks if the path contains ".." components in a platform-agnostic
39+
// way.
40+
func hasDotDot(path string) bool {
41+
// If we are on Windows, strip any volume letters. It turns out that
42+
// C:..\foo may (or may not) be a valid pathname and we need to handle that
43+
// leading "..".
44+
path = stripVolume(path)
45+
// Look for "/../" in the path, but we need to handle leading and trailing
46+
// ".."s by adding separators. Doing this with filepath.Separator is ugly
47+
// so just convert to Unix-style "/" first.
48+
path = filepath.ToSlash(path)
49+
return strings.Contains("/"+path+"/", "/../")
50+
}
3051

3152
// SecureJoinVFS joins the two given path components (similar to [filepath.Join]) except
3253
// that the returned path is guaranteed to be scoped inside the provided root
@@ -58,11 +79,12 @@ var errUncleanRoot = errors.New("root path provided to SecureJoin was not filepa
5879
// avoid containing symlink components. Of course, the root also *must not* be
5980
// attacker-controlled.
6081
func SecureJoinVFS(root, unsafePath string, vfs VFS) (string, error) {
61-
// The root path needs to be clean, otherwise when we join the subpath we
62-
// will end up with a weird path. We could work around this but users
63-
// should not be giving us unclean paths in the first place.
64-
if filepath.Clean(root) != root {
65-
return "", errUncleanRoot
82+
// The root path must not contain ".." components, otherwise when we join
83+
// the subpath we will end up with a weird path. We could work around this
84+
// in other ways but users shouldn't be giving us non-lexical root paths in
85+
// the first place.
86+
if hasDotDot(root) {
87+
return "", errUnsafeRoot
6688
}
6789

6890
// Use the os.* VFS implementation if none was specified.
@@ -77,9 +99,10 @@ func SecureJoinVFS(root, unsafePath string, vfs VFS) (string, error) {
7799
linksWalked int
78100
)
79101
for remainingPath != "" {
80-
if v := filepath.VolumeName(remainingPath); v != "" {
81-
remainingPath = remainingPath[len(v):]
82-
}
102+
// On Windows, if we managed to end up at a path referencing a volume,
103+
// drop the volume to make sure we don't end up with broken paths or
104+
// escaping the root volume.
105+
remainingPath = stripVolume(remainingPath)
83106

84107
// Get the next path component.
85108
var part string

join_test.go

Lines changed: 57 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Copyright (C) 2017-2024 SUSE LLC. All rights reserved.
1+
// Copyright (C) 2017-2025 SUSE LLC. All rights reserved.
22
// Use of this source code is governed by a BSD-style
33
// license that can be found in the LICENSE file.
44

@@ -12,6 +12,8 @@ import (
1212
"runtime"
1313
"syscall"
1414
"testing"
15+
16+
"github.com/stretchr/testify/assert"
1517
)
1618

1719
// TODO: These tests won't work on plan9 because it doesn't have symlinks, and
@@ -392,8 +394,59 @@ func TestSecureJoinVFSErrors(t *testing.T) {
392394
}
393395

394396
func TestUncleanRoot(t *testing.T) {
395-
safePath, err := SecureJoin("/foo/..", "bar/baz")
396-
if !errors.Is(err, errUncleanRoot) {
397-
t.Errorf("SecureJoin with non-clean path should return errUncleanRoot, instead got (%q, %v)", safePath, err)
397+
root := t.TempDir()
398+
399+
for _, test := range []struct {
400+
testName, root string
401+
expectedErr error
402+
}{
403+
{"trailing-dotdot", "foo/..", errUnsafeRoot},
404+
{"leading-dotdot", "../foo", errUnsafeRoot},
405+
{"middle-dotdot", "../foo", errUnsafeRoot},
406+
{"many-dotdot", "foo/../foo/../a", errUnsafeRoot},
407+
{"trailing-slash", root + "/foo/bar/", nil},
408+
{"trailing-slashes", root + "/foo/bar///", nil},
409+
{"many-slashes", root + "/foo///bar////baz", nil},
410+
{"plain-dot", root + "/foo/./bar", nil},
411+
{"many-dot", root + "/foo/./bar/./.", nil},
412+
{"unclean-safe", root + "/foo///./bar/.///.///", nil},
413+
{"unclean-unsafe", root + "/foo///./bar/..///.///", errUnsafeRoot},
414+
} {
415+
test := test // copy iterator
416+
t.Run(test.testName, func(t *testing.T) {
417+
_, err := SecureJoin(test.root, "foo/bar/baz")
418+
if test.expectedErr != nil {
419+
assert.ErrorIsf(t, err, test.expectedErr, "SecureJoin with unsafe root %q", test.root)
420+
} else {
421+
assert.NoErrorf(t, err, "SecureJoin with safe but unclean root %q", test.root)
422+
}
423+
})
424+
}
425+
}
426+
427+
func TestHasDotDot(t *testing.T) {
428+
for _, test := range []struct {
429+
testName, path string
430+
expected bool
431+
}{
432+
{"plain-dotdot", "..", true},
433+
{"trailing-dotdot", "foo/bar/baz/..", true},
434+
{"leading-dotdot", "../foo/bar/baz", true},
435+
{"middle-dotdot", "foo/bar/../baz", true},
436+
{"dotdot-in-name1", "foo/..bar/baz", false},
437+
{"dotdot-in-name2", "foo/bar../baz", false},
438+
{"dotdot-in-name3", "foo/b..r/baz", false},
439+
{"dotdot-in-name4", "..foo/bar/baz", false},
440+
{"dotdot-in-name5", "foo/bar/baz..", false},
441+
{"dot1", "./foo/bar/baz", false},
442+
{"dot2", "foo/bar/baz/.", false},
443+
{"dot3", "foo/././bar/baz", false},
444+
{"unclean", "foo//.//bar/baz////", false},
445+
} {
446+
test := test // copy iterator
447+
t.Run(test.testName, func(t *testing.T) {
448+
got := hasDotDot(test.path)
449+
assert.Equalf(t, test.expected, got, "unexpected result for hasDotDot(%q)", test.path)
450+
})
398451
}
399452
}

join_windows_test.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// Copyright (C) 2017-2025 SUSE LLC. All rights reserved.
2+
// Use of this source code is governed by a BSD-style
3+
// license that can be found in the LICENSE file.
4+
5+
package securejoin
6+
7+
import (
8+
"path/filepath"
9+
"testing"
10+
11+
"github.com/stretchr/testify/assert"
12+
)
13+
14+
// Windows has very specific behaviour relating to volumes, and we can only
15+
// test it on Windows machines because filepath.* behaviour depends on GOOS.
16+
//
17+
// See <https://learn.microsoft.com/en-us/dotnet/standard/io/file-path-formats>
18+
// for more information about the various path formats we need to make sure are
19+
// correctly handled.
20+
func TestHasDotDot_WindowsVolumes(t *testing.T) {
21+
for _, test := range []struct {
22+
testName, path string
23+
expected bool
24+
}{
25+
{"plain-dotdot", `C:..`, true}, // apparently legal
26+
{"relative-dotdot", `C:..\foo\bar`, true}, // apparently legal
27+
{"trailing-dotdot", `D:\foo\bar\..`, true},
28+
{"leading-dotdot", `F:\..\foo\bar`, true},
29+
{"middle-dotdot", `F:\foo\..\bar`, true},
30+
{"drive-like-path", `\foo\C:..\bar`, false}, // C:.. is a filename here
31+
{"unc-dotdot", `\\gondor\share\call\for\aid\..\help`, true},
32+
{"dos-dotpath-dotdot1", `\\.\C:\..\foo\bar`, true},
33+
{"dos-dotpath-dotdot2", `\\.\C:\foo\..\bar`, true},
34+
{"dos-questionpath-dotdot1", `\\?\C:\..\foo\bar`, true},
35+
{"dos-questionpath-dotdot2", `\\?\C:\foo\..\bar`, true},
36+
} {
37+
test := test // copy iterator
38+
t.Run(test.testName, func(t *testing.T) {
39+
got := hasDotDot(test.path)
40+
assert.Equalf(t, test.expected, got, "unexpected result for hasDotDot(`%s`) (VolumeName: %q)", test.path, filepath.VolumeName(test.path))
41+
})
42+
}
43+
}

0 commit comments

Comments
 (0)