Skip to content

Commit 22faec1

Browse files
committed
WIP: port SecureJoin to partialLookupInRoot
Signed-off-by: Aleksa Sarai <cyphar@cyphar.com>
1 parent dc44935 commit 22faec1

File tree

11 files changed

+194
-42
lines changed

11 files changed

+194
-42
lines changed

join.go renamed to join_generic.go

Lines changed: 1 addition & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -29,29 +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-
// NOTE: Due to the above limitation, Linux users are strongly encouraged to
45-
// use OpenInRoot instead, which does safely protect against these kinds of
46-
// attacks. There is no way to solve this problem with SecureJoinVFS because
47-
// the API is fundamentally wrong (you cannot return a "safe" path string and
48-
// guarantee it won't be modified afterwards).
49-
//
50-
// Volume names in unsafePath are always discarded, regardless if they are
51-
// provided via direct input or when evaluating symlinks. Therefore:
52-
//
53-
// "C:\Temp" + "D:\path\to\file.txt" results in "C:\Temp\path\to\file.txt"
54-
func SecureJoinVFS(root, unsafePath string, vfs VFS) (string, error) {
32+
func legacySecureJoinVFS(root, unsafePath string, vfs VFS) (string, error) {
5533
// Use the os.* VFS implementation if none was specified.
5634
if vfs == nil {
5735
vfs = osVFS{}
@@ -122,9 +100,3 @@ func SecureJoinVFS(root, unsafePath string, vfs VFS) (string, error) {
122100
finalPath := filepath.Join(string(filepath.Separator), currentPath)
123101
return filepath.Join(root, finalPath), nil
124102
}
125-
126-
// SecureJoin is a wrapper around SecureJoinVFS that just uses the os.* library
127-
// of functions as the VFS. If in doubt, use this function over SecureJoinVFS.
128-
func SecureJoin(root, unsafePath string) (string, error) {
129-
return SecureJoinVFS(root, unsafePath, nil)
130-
}

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_linux_test.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
// Copyright (C) 2017-2024 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+
"testing"
9+
)
10+
11+
func TestSymlink(t *testing.T) {
12+
withWithoutOpenat2(t, true, testSymlink)
13+
}

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: 1 addition & 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)

lookup_linux.go

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -153,11 +153,23 @@ func (s *symlinkStack) PopTopSymlink() (*os.File, string, bool) {
153153
return tailEntry.dir, tailEntry.remainingPath, true
154154
}
155155

156+
const maxUnsafeHallucinateDirectoryTries = 20
157+
158+
var errTooManyFakeDirectories = errors.New("encountered too many non-existent paths")
159+
156160
// partialLookupInRoot tries to lookup as much of the request path as possible
157161
// within the provided root (a-la RESOLVE_IN_ROOT) and opens the final existing
158162
// component of the requested path, returning a file handle to the final
159163
// existing component and a string containing the remaining path components.
160-
func partialLookupInRoot(root *os.File, unsafePath string) (_ *os.File, _ string, Err error) {
164+
//
165+
// If unsafeHallucinateDirectories is true, partialLookupInRoot will try to
166+
// emulate the legacy SecureJoin behaviour of treating non-existent paths as
167+
// though they are directories to try to resolve as much of the path as
168+
// possible. In practice, this means that a path like "a/b/doesnotexist/../c"
169+
// will end up being resolved as "a/b/c" if possible. Note that dangling
170+
// symlinks (a symlink that points to a non-existent path) will still result in
171+
// an error being returned, due to how openat2 handles symlinks.
172+
func partialLookupInRoot(root *os.File, unsafePath string, unsafeHallucinateDirectories bool) (_ *os.File, _ string, Err error) {
161173
unsafePath = filepath.ToSlash(unsafePath) // noop
162174

163175
// This is very similar to SecureJoin, except that we operate on the
@@ -166,7 +178,7 @@ func partialLookupInRoot(root *os.File, unsafePath string) (_ *os.File, _ string
166178

167179
// Try to use openat2 if possible.
168180
if hasOpenat2() {
169-
return partialLookupOpenat2(root, unsafePath)
181+
return partialLookupOpenat2(root, unsafePath, unsafeHallucinateDirectories)
170182
}
171183

172184
// Get the "actual" root path from /proc/self/fd. This is necessary if the
@@ -204,7 +216,9 @@ func partialLookupInRoot(root *os.File, unsafePath string) (_ *os.File, _ string
204216
defer symlinkStack.Close()
205217

206218
var (
207-
linksWalked int
219+
linksWalked int
220+
hallucinateDirectoryTries int
221+
208222
currentPath string
209223
remainingPath = unsafePath
210224
)
@@ -354,6 +368,21 @@ func partialLookupInRoot(root *os.File, unsafePath string) (_ *os.File, _ string
354368
_ = currentDir.Close()
355369
return oldDir, remainingPath, nil
356370
}
371+
// If we were asked to "hallucinate" non-existent paths as though
372+
// they are directories, take the remainingPath and clean it so
373+
// that any ".." components that would lead us back to real paths
374+
// can get resolved.
375+
if oldRemainingPath != "" && unsafeHallucinateDirectories {
376+
if newRemainingPath := path.Clean(oldRemainingPath); newRemainingPath != oldRemainingPath {
377+
hallucinateDirectoryTries++
378+
if hallucinateDirectoryTries > maxUnsafeHallucinateDirectoryTries {
379+
return nil, "", fmt.Errorf("%w: trying to reconcile non-existent subpath %q", errTooManyFakeDirectories, oldRemainingPath)
380+
}
381+
// Continue the lookup using the new remaining path.
382+
remainingPath = newRemainingPath
383+
continue
384+
}
385+
}
357386
// We have hit a final component that doesn't exist, so we have our
358387
// partial open result. Note that we have to use the OLD remaining
359388
// path, since the lookup failed.

lookup_linux_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import (
1919
"golang.org/x/sys/unix"
2020
)
2121

22-
type partialLookupFunc func(root *os.File, unsafePath string) (*os.File, string, error)
22+
type partialLookupFunc func(root *os.File, unsafePath string, hallucinateDirectoryTries bool) (*os.File, string, error)
2323

2424
type lookupResult struct {
2525
handlePath, remainingPath string
@@ -28,7 +28,7 @@ type lookupResult struct {
2828
}
2929

3030
func checkPartialLookup(t *testing.T, partialLookupFn partialLookupFunc, rootDir *os.File, unsafePath string, expected lookupResult) {
31-
handle, remainingPath, err := partialLookupFn(rootDir, unsafePath)
31+
handle, remainingPath, err := partialLookupFn(rootDir, unsafePath, false)
3232
if handle != nil {
3333
defer handle.Close()
3434
}
@@ -325,7 +325,7 @@ func newRacingLookupMeta(pauseCh chan struct{}) *racingLookupMeta {
325325
func (m *racingLookupMeta) checkPartialLookup(t *testing.T, rootDir *os.File, unsafePath string, skipErrs []error, allowedResults []lookupResult) {
326326
// Similar to checkPartialLookup, but with extra logic for
327327
// handling the lookup stopping partly through the lookup.
328-
handle, remainingPath, err := partialLookupInRoot(rootDir, unsafePath)
328+
handle, remainingPath, err := partialLookupInRoot(rootDir, unsafePath, false)
329329
if err != nil {
330330
for _, skipErr := range skipErrs {
331331
if errors.Is(err, skipErr) {

mkdir_linux.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ func MkdirAllHandle(root *os.File, unsafePath string, mode int) (_ *os.File, Err
4848
}
4949

5050
// Try to open as much of the path as possible.
51-
currentDir, remainingPath, err := partialLookupInRoot(root, unsafePath)
51+
currentDir, remainingPath, err := partialLookupInRoot(root, unsafePath, false)
5252
if err != nil {
5353
return nil, fmt.Errorf("find existing subpath of %q: %w", unsafePath, err)
5454
}

mkdir_linux_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ func testMkdirAll_Basic(t *testing.T, mkdirAll func(t *testing.T, root, unsafePa
110110

111111
// Before trying to make the tree, figure out what
112112
// components don't exist yet so we can check them later.
113-
handle, remainingPath, err := partialLookupInRoot(rootDir, test.unsafePath)
113+
handle, remainingPath, err := partialLookupInRoot(rootDir, test.unsafePath, false)
114114
handleName := "<nil>"
115115
if handle != nil {
116116
handleName = handle.Name()

open_linux.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import (
1616
// OpenatInRoot is equivalent to OpenInRoot, except that the root is provided
1717
// using an *os.File handle, to ensure that the correct root directory is used.
1818
func OpenatInRoot(root *os.File, unsafePath string) (*os.File, error) {
19-
handle, remainingPath, err := partialLookupInRoot(root, unsafePath)
19+
handle, remainingPath, err := partialLookupInRoot(root, unsafePath, false)
2020
if err != nil {
2121
return nil, err
2222
}

0 commit comments

Comments
 (0)