Skip to content

Commit b7d9a84

Browse files
committed
WIP: port SecureJoin to partialLookupInRoot
Signed-off-by: Aleksa Sarai <[email protected]>
1 parent 2b9335a commit b7d9a84

File tree

8 files changed

+183
-33
lines changed

8 files changed

+183
-33
lines changed

join.go renamed to join_generic.go

Lines changed: 1 addition & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -29,23 +29,7 @@ func IsNotExist(err error) bool {
2929
return errors.Is(err, os.ErrNotExist) || errors.Is(err, syscall.ENOTDIR) || errors.Is(err, syscall.ENOENT)
3030
}
3131

32-
// SecureJoinVFS joins the two given path components (similar to Join) except
33-
// that the returned path is guaranteed to be scoped inside the provided root
34-
// path (when evaluated). Any symbolic links in the path are evaluated with the
35-
// given root treated as the root of the filesystem, similar to a chroot. The
36-
// filesystem state is evaluated through the given VFS interface (if nil, the
37-
// standard os.* family of functions are used).
38-
//
39-
// Note that the guarantees provided by this function only apply if the path
40-
// components in the returned string are not modified (in other words are not
41-
// replaced with symlinks on the filesystem) after this function has returned.
42-
// Such a symlink race is necessarily out-of-scope of SecureJoin.
43-
//
44-
// Volume names in unsafePath are always discarded, regardless if they are
45-
// provided via direct input or when evaluating symlinks. Therefore:
46-
//
47-
// "C:\Temp" + "D:\path\to\file.txt" results in "C:\Temp\path\to\file.txt"
48-
func SecureJoinVFS(root, unsafePath string, vfs VFS) (string, error) {
32+
func legacySecureJoinVFS(root, unsafePath string, vfs VFS) (string, error) {
4933
// Use the os.* VFS implementation if none was specified.
5034
if vfs == nil {
5135
vfs = osVFS{}
@@ -116,9 +100,3 @@ func SecureJoinVFS(root, unsafePath string, vfs VFS) (string, error) {
116100
finalPath := filepath.Join(string(filepath.Separator), currentPath)
117101
return filepath.Join(root, finalPath), nil
118102
}
119-
120-
// SecureJoin is a wrapper around SecureJoinVFS that just uses the os.* library
121-
// of functions as the VFS. If in doubt, use this function over SecureJoinVFS.
122-
func SecureJoin(root, unsafePath string) (string, error) {
123-
return SecureJoinVFS(root, unsafePath, nil)
124-
}

join_linux.go

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
//go:build linux
2+
3+
// Copyright (C) 2024 SUSE LLC. All rights reserved.
4+
// Use of this source code is governed by a BSD-style
5+
// license that can be found in the LICENSE file.
6+
7+
package securejoin
8+
9+
import (
10+
"fmt"
11+
"os"
12+
"path/filepath"
13+
"strings"
14+
15+
"golang.org/x/sys/unix"
16+
)
17+
18+
func isLexicallyInRoot(root, path string) bool {
19+
if root != "/" {
20+
root += "/"
21+
}
22+
if path != "/" {
23+
path += "/"
24+
}
25+
return strings.HasPrefix(path, root)
26+
}
27+
28+
// SecureJoin is a wrapper around SecureJoinVFS that just uses the os.* library
29+
// of functions as the VFS. If in doubt, use this function over SecureJoinVFS.
30+
func SecureJoin(root, unsafePath string) (string, error) {
31+
rootDir, err := os.OpenFile(root, unix.O_PATH|unix.O_DIRECTORY|unix.O_CLOEXEC, 0)
32+
if err != nil {
33+
return "", err
34+
}
35+
defer rootDir.Close()
36+
37+
handle, remainingPath, err := partialLookupInRoot(rootDir, unsafePath, true)
38+
if err != nil {
39+
return "", err
40+
}
41+
defer handle.Close()
42+
43+
handlePath, err := procSelfFdReadlink(handle)
44+
if err != nil {
45+
return "", fmt.Errorf("verify actual path of %q handle: %w", handle.Name(), err)
46+
}
47+
// Make sure the path is inside the root.
48+
if !isLexicallyInRoot(root, handlePath) {
49+
return "", fmt.Errorf("%w: handle path %q is outside root %q", errPossibleBreakout, handlePath, root)
50+
}
51+
52+
// remainingPath should be cleaned and safe to append, due to how
53+
// unsafeHallucinateDirectories works. But do an additional cleanup, just
54+
// to be sure.
55+
remainingPath = filepath.Join("/", remainingPath)
56+
return filepath.Join(handlePath, remainingPath), nil
57+
}
58+
59+
// SecureJoinVFS joins the two given path components (similar to Join) except
60+
// that the returned path is guaranteed to be scoped inside the provided root
61+
// path (when evaluated). Any symbolic links in the path are evaluated with the
62+
// given root treated as the root of the filesystem, similar to a chroot. The
63+
// filesystem state is evaluated through the given VFS interface (if nil, the
64+
// standard os.* family of functions are used).
65+
//
66+
// Note that the guarantees provided by this function only apply if the path
67+
// components in the returned string are not modified (in other words are not
68+
// replaced with symlinks on the filesystem) after this function has returned.
69+
// Such a symlink race is necessarily out-of-scope of SecureJoin.
70+
//
71+
// Volume names in unsafePath are always discarded, regardless if they are
72+
// provided via direct input or when evaluating symlinks. Therefore:
73+
//
74+
// "C:\Temp" + "D:\path\to\file.txt" results in "C:\Temp\path\to\file.txt"
75+
func SecureJoinVFS(root, unsafePath string, vfs VFS) (string, error) {
76+
if vfs == nil || vfs == (osVFS{}) {
77+
return SecureJoin(root, unsafePath)
78+
}
79+
// TODO: Make it possible for partialLookupInRoot to work with VFS.
80+
return legacySecureJoinVFS(root, unsafePath, vfs)
81+
}

join_nonlinux.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
//go:build !linux
2+
3+
// Copyright (C) 2024 SUSE LLC. All rights reserved.
4+
// Use of this source code is governed by a BSD-style
5+
// license that can be found in the LICENSE file.
6+
7+
package securejoin
8+
9+
// SecureJoin is a wrapper around SecureJoinVFS that just uses the os.* library
10+
// of functions as the VFS. If in doubt, use this function over SecureJoinVFS.
11+
func SecureJoin(root, unsafePath string) (string, error) {
12+
return SecureJoinVFS(root, unsafePath, nil)
13+
}
14+
15+
// SecureJoinVFS joins the two given path components (similar to Join) except
16+
// that the returned path is guaranteed to be scoped inside the provided root
17+
// path (when evaluated). Any symbolic links in the path are evaluated with the
18+
// given root treated as the root of the filesystem, similar to a chroot. The
19+
// filesystem state is evaluated through the given VFS interface (if nil, the
20+
// standard os.* family of functions are used).
21+
//
22+
// Note that the guarantees provided by this function only apply if the path
23+
// components in the returned string are not modified (in other words are not
24+
// replaced with symlinks on the filesystem) after this function has returned.
25+
// Such a symlink race is necessarily out-of-scope of SecureJoin.
26+
//
27+
// Volume names in unsafePath are always discarded, regardless if they are
28+
// provided via direct input or when evaluating symlinks. Therefore:
29+
//
30+
// "C:\Temp" + "D:\path\to\file.txt" results in "C:\Temp\path\to\file.txt"
31+
func SecureJoinVFS(root, unsafePath string, vfs VFS) (string, error) {
32+
return legacySecureJoinVFS(root, unsafePath, vfs)
33+
}

join_test.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ type input struct {
3232
}
3333

3434
// Test basic handling of symlink expansion.
35-
func TestSymlink(t *testing.T) {
35+
func testSymlink(t *testing.T) {
3636
dir := t.TempDir()
3737
dir, err := filepath.EvalSymlinks(dir)
3838
require.NoError(t, err)
@@ -77,6 +77,10 @@ func TestSymlink(t *testing.T) {
7777
}
7878
}
7979

80+
func TestSymlink(t *testing.T) {
81+
withWithoutOpenat2(t, testSymlink)
82+
}
83+
8084
// In a path without symlinks, SecureJoin is equivalent to Clean+Join.
8185
func TestNoSymlink(t *testing.T) {
8286
dir := t.TempDir()

lookup_linux.go

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -116,11 +116,23 @@ func (s *symlinkStack) PopLastSymlink() (*os.File, string, bool) {
116116
return tailEntry.dir, tailEntry.remainingPath, true
117117
}
118118

119+
const maxUnsafeHallucinateDirectoryTries = 20
120+
121+
var errTooManyFakeDirectories = errors.New("encountered too many non-existent paths")
122+
119123
// partialLookupInRoot tries to lookup as much of the request path as possible
120124
// within the provided root (a-la RESOLVE_IN_ROOT) and opens the final existing
121125
// component of the requested path, returning a file handle to the final
122126
// existing component and a string containing the remaining path components.
123-
func partialLookupInRoot(root *os.File, unsafePath string) (_ *os.File, _ string, Err error) {
127+
//
128+
// If unsafeHallucinateDirectories is true, partialLookupInRoot will try to
129+
// emulate the legacy SecureJoin behaviour of treating non-existent paths as
130+
// though they are directories to try to resolve as much of the path as
131+
// possible. In practice, this means that a path like "a/b/doesnotexist/../c"
132+
// will end up being resolved as "a/b/c" if possible. Note that dangling
133+
// symlinks (a symlink that points to a non-existent path) will still result in
134+
// an error being returned, due to how openat2 handles symlinks.
135+
func partialLookupInRoot(root *os.File, unsafePath string, unsafeHallucinateDirectories bool) (_ *os.File, _ string, Err error) {
124136
unsafePath = filepath.ToSlash(unsafePath) // noop
125137

126138
// This is very similar to SecureJoin, except that we operate on the
@@ -129,7 +141,7 @@ func partialLookupInRoot(root *os.File, unsafePath string) (_ *os.File, _ string
129141

130142
// Try to use openat2 if possible.
131143
if hasOpenat2() {
132-
return partialLookupOpenat2(root, unsafePath)
144+
return partialLookupOpenat2(root, unsafePath, unsafeHallucinateDirectories)
133145
}
134146

135147
// Get the "actual" root path from /proc/self/fd. This is necessary if the
@@ -168,7 +180,9 @@ func partialLookupInRoot(root *os.File, unsafePath string) (_ *os.File, _ string
168180
defer symlinkStack.Close()
169181

170182
var (
171-
linksWalked int
183+
linksWalked int
184+
hallucinateDirectoryTries int
185+
172186
currentPath string
173187
remainingPath = unsafePath
174188
)
@@ -235,7 +249,12 @@ func partialLookupInRoot(root *os.File, unsafePath string) (_ *os.File, _ string
235249
return nil, "", fmt.Errorf("root path moved during lookup: %w", err)
236250
}
237251
// Make sure the path is what we expect.
238-
fullPath := logicalRootPath + nextPath
252+
var fullPath string
253+
if logicalRootPath == "/" {
254+
fullPath = nextPath
255+
} else {
256+
fullPath = logicalRootPath + nextPath
257+
}
239258
if err := checkProcSelfFdPath(fullPath, nextDir); err != nil {
240259
return nil, "", fmt.Errorf("walking into .. had unexpected result: %w", err)
241260
}
@@ -250,6 +269,21 @@ func partialLookupInRoot(root *os.File, unsafePath string) (_ *os.File, _ string
250269
_ = currentDir.Close()
251270
return oldDir, remainingPath, nil
252271
}
272+
// If we were asked to "hallucinate" non-existent paths as though
273+
// they are directories, take the remainingPath and clean it so
274+
// that any ".." components that would lead us back to real paths
275+
// can get resolved.
276+
if oldRemainingPath != "" && unsafeHallucinateDirectories {
277+
if newRemainingPath := path.Clean(oldRemainingPath); newRemainingPath != oldRemainingPath {
278+
hallucinateDirectoryTries++
279+
if hallucinateDirectoryTries > maxUnsafeHallucinateDirectoryTries {
280+
return nil, "", fmt.Errorf("%w: trying to reconcile non-existent subpath %q", errTooManyFakeDirectories, oldRemainingPath)
281+
}
282+
// Continue the lookup using the new remaining path.
283+
remainingPath = newRemainingPath
284+
continue
285+
}
286+
}
253287
// We have hit a final component that doesn't exist, so we have our
254288
// partial open result. Note that we have to use the OLD remaining
255289
// path, since the lookup failed.

mkdir_linux.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ var errPossibleAttack = errors.New("possible attack detected")
4040
// should use MkdirAllHandle.
4141
func MkdirAllHandle(root *os.File, unsafePath string, mode os.FileMode) (_ *os.File, Err error) {
4242
// Try to open as much of the path as possible.
43-
currentDir, remainingPath, err := partialLookupInRoot(root, unsafePath)
43+
currentDir, remainingPath, err := partialLookupInRoot(root, unsafePath, false)
4444
if err != nil {
4545
return nil, fmt.Errorf("find existing subpath of %q: %w", unsafePath, err)
4646
}

mkdir_linux_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ func testMkdirAllBasic(t *testing.T, mkdirAll func(t *testing.T, root, unsafePat
9595

9696
// Before trying to make the tree, figure out what
9797
// components don't exist yet so we can check them later.
98-
handle, remainingPath, err := partialLookupInRoot(rootDir, test.unsafePath)
98+
handle, remainingPath, err := partialLookupInRoot(rootDir, test.unsafePath, false)
9999
handleName := "<nil>"
100100
if handle != nil {
101101
handleName = handle.Name()

openat2_linux.go

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -85,14 +85,16 @@ func openat2File(dir *os.File, path string, how *unix.OpenHow) (*os.File, error)
8585
// partialLookupOpenat2 is an alternative implementation of
8686
// partialLookupInRoot, using openat2(RESOLVE_IN_ROOT) to more safely get a
8787
// handle to the deepest existing child of the requested path within the root.
88-
func partialLookupOpenat2(root *os.File, unsafePath string) (*os.File, string, error) {
88+
func partialLookupOpenat2(root *os.File, unsafePath string, unsafeHallucinateDirectories bool) (*os.File, string, error) {
89+
unsafePath = filepath.ToSlash(unsafePath) // noop
90+
8991
if !hasOpenat2() {
9092
return nil, "", fmt.Errorf("openat2: %w", unix.ENOTSUP)
9193
}
9294

9395
// TODO: Implement this as a git-bisect-like binary search.
9496

95-
unsafePath = filepath.ToSlash(unsafePath) // noop
97+
var hallucinateDirectoryTries int
9698
endIdx := len(unsafePath)
9799
for endIdx > 0 {
98100
subpath := unsafePath[:endIdx]
@@ -104,7 +106,25 @@ func partialLookupOpenat2(root *os.File, unsafePath string) (*os.File, string, e
104106
})
105107
if err == nil {
106108
// We found a subpath!
107-
return handle, unsafePath[endIdx:], nil
109+
remainingPath := unsafePath[endIdx:]
110+
// If we were asked to "hallucinate" non-existent paths as though
111+
// they are directories, take the remainingPath and clean it so
112+
// that any ".." components that would lead us back to real paths
113+
// can get resolved.
114+
if remainingPath != "" && unsafeHallucinateDirectories {
115+
if newRemainingPath := filepath.Clean(remainingPath); newRemainingPath != remainingPath {
116+
hallucinateDirectoryTries++
117+
if hallucinateDirectoryTries > maxUnsafeHallucinateDirectoryTries {
118+
return nil, "", fmt.Errorf("%w: trying to reconcile non-existent subpath %q", errTooManyFakeDirectories, remainingPath)
119+
}
120+
// Start the lookup from the end again using the new
121+
// remaining path.
122+
unsafePath = subpath + "/" + newRemainingPath
123+
endIdx = len(unsafePath)
124+
continue
125+
}
126+
}
127+
return handle, remainingPath, nil
108128
}
109129
if !errors.Is(err, os.ErrNotExist) {
110130
return nil, "", fmt.Errorf("open subpath: %w", err)

0 commit comments

Comments
 (0)