forked from toolshed/abra
		
	We were running behind and there were quite some deprecations to update. This was mostly in the upstream copy/pasta package but seems quite minimal.
		
			
				
	
	
		
			167 lines
		
	
	
		
			6.4 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
			
		
		
	
	
			167 lines
		
	
	
		
			6.4 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
| // Copyright (C) 2014-2015 Docker Inc & Go Authors. All rights reserved.
 | |
| // Copyright (C) 2017-2025 SUSE LLC. All rights reserved.
 | |
| // Use of this source code is governed by a BSD-style
 | |
| // license that can be found in the LICENSE file.
 | |
| 
 | |
| package securejoin
 | |
| 
 | |
| import (
 | |
| 	"errors"
 | |
| 	"os"
 | |
| 	"path/filepath"
 | |
| 	"strings"
 | |
| 	"syscall"
 | |
| )
 | |
| 
 | |
| const maxSymlinkLimit = 255
 | |
| 
 | |
| // IsNotExist tells you if err is an error that implies that either the path
 | |
| // accessed does not exist (or path components don't exist). This is
 | |
| // effectively a more broad version of [os.IsNotExist].
 | |
| func IsNotExist(err error) bool {
 | |
| 	// Check that it's not actually an ENOTDIR, which in some cases is a more
 | |
| 	// convoluted case of ENOENT (usually involving weird paths).
 | |
| 	return errors.Is(err, os.ErrNotExist) || errors.Is(err, syscall.ENOTDIR) || errors.Is(err, syscall.ENOENT)
 | |
| }
 | |
| 
 | |
| // errUnsafeRoot is returned if the user provides SecureJoinVFS with a path
 | |
| // that contains ".." components.
 | |
| var errUnsafeRoot = errors.New("root path provided to SecureJoin contains '..' components")
 | |
| 
 | |
| // stripVolume just gets rid of the Windows volume included in a path. Based on
 | |
| // some godbolt tests, the Go compiler is smart enough to make this a no-op on
 | |
| // Linux.
 | |
| func stripVolume(path string) string {
 | |
| 	return path[len(filepath.VolumeName(path)):]
 | |
| }
 | |
| 
 | |
| // hasDotDot checks if the path contains ".." components in a platform-agnostic
 | |
| // way.
 | |
| func hasDotDot(path string) bool {
 | |
| 	// If we are on Windows, strip any volume letters. It turns out that
 | |
| 	// C:..\foo may (or may not) be a valid pathname and we need to handle that
 | |
| 	// leading "..".
 | |
| 	path = stripVolume(path)
 | |
| 	// Look for "/../" in the path, but we need to handle leading and trailing
 | |
| 	// ".."s by adding separators. Doing this with filepath.Separator is ugly
 | |
| 	// so just convert to Unix-style "/" first.
 | |
| 	path = filepath.ToSlash(path)
 | |
| 	return strings.Contains("/"+path+"/", "/../")
 | |
| }
 | |
| 
 | |
| // SecureJoinVFS joins the two given path components (similar to [filepath.Join]) except
 | |
| // that the returned path is guaranteed to be scoped inside the provided root
 | |
| // path (when evaluated). Any symbolic links in the path are evaluated with the
 | |
| // given root treated as the root of the filesystem, similar to a chroot. The
 | |
| // filesystem state is evaluated through the given [VFS] interface (if nil, the
 | |
| // standard [os].* family of functions are used).
 | |
| //
 | |
| // Note that the guarantees provided by this function only apply if the path
 | |
| // components in the returned string are not modified (in other words are not
 | |
| // replaced with symlinks on the filesystem) after this function has returned.
 | |
| // Such a symlink race is necessarily out-of-scope of SecureJoinVFS.
 | |
| //
 | |
| // NOTE: Due to the above limitation, Linux users are strongly encouraged to
 | |
| // use [OpenInRoot] instead, which does safely protect against these kinds of
 | |
| // attacks. There is no way to solve this problem with SecureJoinVFS because
 | |
| // the API is fundamentally wrong (you cannot return a "safe" path string and
 | |
| // guarantee it won't be modified afterwards).
 | |
| //
 | |
| // Volume names in unsafePath are always discarded, regardless if they are
 | |
| // provided via direct input or when evaluating symlinks. Therefore:
 | |
| //
 | |
| // "C:\Temp" + "D:\path\to\file.txt" results in "C:\Temp\path\to\file.txt"
 | |
| //
 | |
| // If the provided root is not [filepath.Clean] then an error will be returned,
 | |
| // as such root paths are bordering on somewhat unsafe and using such paths is
 | |
| // not best practice. We also strongly suggest that any root path is first
 | |
| // fully resolved using [filepath.EvalSymlinks] or otherwise constructed to
 | |
| // avoid containing symlink components. Of course, the root also *must not* be
 | |
| // attacker-controlled.
 | |
| func SecureJoinVFS(root, unsafePath string, vfs VFS) (string, error) {
 | |
| 	// The root path must not contain ".." components, otherwise when we join
 | |
| 	// the subpath we will end up with a weird path. We could work around this
 | |
| 	// in other ways but users shouldn't be giving us non-lexical root paths in
 | |
| 	// the first place.
 | |
| 	if hasDotDot(root) {
 | |
| 		return "", errUnsafeRoot
 | |
| 	}
 | |
| 
 | |
| 	// Use the os.* VFS implementation if none was specified.
 | |
| 	if vfs == nil {
 | |
| 		vfs = osVFS{}
 | |
| 	}
 | |
| 
 | |
| 	unsafePath = filepath.FromSlash(unsafePath)
 | |
| 	var (
 | |
| 		currentPath   string
 | |
| 		remainingPath = unsafePath
 | |
| 		linksWalked   int
 | |
| 	)
 | |
| 	for remainingPath != "" {
 | |
| 		// On Windows, if we managed to end up at a path referencing a volume,
 | |
| 		// drop the volume to make sure we don't end up with broken paths or
 | |
| 		// escaping the root volume.
 | |
| 		remainingPath = stripVolume(remainingPath)
 | |
| 
 | |
| 		// Get the next path component.
 | |
| 		var part string
 | |
| 		if i := strings.IndexRune(remainingPath, filepath.Separator); i == -1 {
 | |
| 			part, remainingPath = remainingPath, ""
 | |
| 		} else {
 | |
| 			part, remainingPath = remainingPath[:i], remainingPath[i+1:]
 | |
| 		}
 | |
| 
 | |
| 		// Apply the component lexically to the path we are building.
 | |
| 		// currentPath does not contain any symlinks, and we are lexically
 | |
| 		// dealing with a single component, so it's okay to do a filepath.Clean
 | |
| 		// here.
 | |
| 		nextPath := filepath.Join(string(filepath.Separator), currentPath, part)
 | |
| 		if nextPath == string(filepath.Separator) {
 | |
| 			currentPath = ""
 | |
| 			continue
 | |
| 		}
 | |
| 		fullPath := root + string(filepath.Separator) + nextPath
 | |
| 
 | |
| 		// Figure out whether the path is a symlink.
 | |
| 		fi, err := vfs.Lstat(fullPath)
 | |
| 		if err != nil && !IsNotExist(err) {
 | |
| 			return "", err
 | |
| 		}
 | |
| 		// Treat non-existent path components the same as non-symlinks (we
 | |
| 		// can't do any better here).
 | |
| 		if IsNotExist(err) || fi.Mode()&os.ModeSymlink == 0 {
 | |
| 			currentPath = nextPath
 | |
| 			continue
 | |
| 		}
 | |
| 
 | |
| 		// It's a symlink, so get its contents and expand it by prepending it
 | |
| 		// to the yet-unparsed path.
 | |
| 		linksWalked++
 | |
| 		if linksWalked > maxSymlinkLimit {
 | |
| 			return "", &os.PathError{Op: "SecureJoin", Path: root + string(filepath.Separator) + unsafePath, Err: syscall.ELOOP}
 | |
| 		}
 | |
| 
 | |
| 		dest, err := vfs.Readlink(fullPath)
 | |
| 		if err != nil {
 | |
| 			return "", err
 | |
| 		}
 | |
| 		remainingPath = dest + string(filepath.Separator) + remainingPath
 | |
| 		// Absolute symlinks reset any work we've already done.
 | |
| 		if filepath.IsAbs(dest) {
 | |
| 			currentPath = ""
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	// There should be no lexical components like ".." left in the path here,
 | |
| 	// but for safety clean up the path before joining it to the root.
 | |
| 	finalPath := filepath.Join(string(filepath.Separator), currentPath)
 | |
| 	return filepath.Join(root, finalPath), nil
 | |
| }
 | |
| 
 | |
| // SecureJoin is a wrapper around [SecureJoinVFS] that just uses the [os].* library
 | |
| // of functions as the [VFS]. If in doubt, use this function over [SecureJoinVFS].
 | |
| func SecureJoin(root, unsafePath string) (string, error) {
 | |
| 	return SecureJoinVFS(root, unsafePath, nil)
 | |
| }
 |