244640d0c1
This function was not actually safe in theory. If an unknown stat source was passed in it would be possible for a symlinked file to not be detected as a symlink, thus skipping any safe path resolutions. This would happen if the stat source was a regular os.Stat call and not an os.Lstat call, but since there is no way to differentiate between those two in the code, it is safer to just manually apply this logic in the positions where we _know_ for certain that we're working with the results of an Lstat call.
142 lines
5.2 KiB
Go
142 lines
5.2 KiB
Go
package filesystem
|
|
|
|
import (
|
|
"context"
|
|
"golang.org/x/sync/errgroup"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
)
|
|
|
|
// Normalizes a directory being passed in to ensure the user is not able to escape
|
|
// from their data directory. After normalization if the directory is still within their home
|
|
// path it is returned. If they managed to "escape" an error will be returned.
|
|
//
|
|
// This logic is actually copied over from the SFTP server code. Ideally that eventually
|
|
// either gets ported into this application, or is able to make use of this package.
|
|
func (fs *Filesystem) SafePath(p string) (string, error) {
|
|
var nonExistentPathResolution string
|
|
|
|
// Start with a cleaned up path before checking the more complex bits.
|
|
r := fs.unsafeFilePath(p)
|
|
|
|
// At the same time, evaluate the symlink status and determine where this file or folder
|
|
// is truly pointing to.
|
|
p, err := filepath.EvalSymlinks(r)
|
|
if err != nil && !os.IsNotExist(err) {
|
|
return "", err
|
|
} else if os.IsNotExist(err) {
|
|
// The requested directory doesn't exist, so at this point we need to iterate up the
|
|
// path chain until we hit a directory that _does_ exist and can be validated.
|
|
parts := strings.Split(filepath.Dir(r), "/")
|
|
|
|
var try string
|
|
// Range over all of the path parts and form directory pathings from the end
|
|
// moving up until we have a valid resolution or we run out of paths to try.
|
|
for k := range parts {
|
|
try = strings.Join(parts[:(len(parts)-k)], "/")
|
|
|
|
if !fs.unsafeIsInDataDirectory(try) {
|
|
break
|
|
}
|
|
|
|
t, err := filepath.EvalSymlinks(try)
|
|
if err == nil {
|
|
nonExistentPathResolution = t
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// If the new path doesn't start with their root directory there is clearly an escape
|
|
// attempt going on, and we should NOT resolve this path for them.
|
|
if nonExistentPathResolution != "" {
|
|
if !fs.unsafeIsInDataDirectory(nonExistentPathResolution) {
|
|
return "", ErrBadPathResolution
|
|
}
|
|
|
|
// If the nonExistentPathResolution variable is not empty then the initial path requested
|
|
// did not exist and we looped through the pathway until we found a match. At this point
|
|
// we've confirmed the first matched pathway exists in the root server directory, so we
|
|
// can go ahead and just return the path that was requested initially.
|
|
return r, nil
|
|
}
|
|
|
|
// If the requested directory from EvalSymlinks begins with the server root directory go
|
|
// ahead and return it. If not we'll return an error which will block any further action
|
|
// on the file.
|
|
if fs.unsafeIsInDataDirectory(p) {
|
|
return p, nil
|
|
}
|
|
|
|
return "", ErrBadPathResolution
|
|
}
|
|
|
|
// Generate a path to the file by cleaning it up and appending the root server path to it. This
|
|
// DOES NOT guarantee that the file resolves within the server data directory. You'll want to use
|
|
// the fs.unsafeIsInDataDirectory(p) function to confirm.
|
|
func (fs *Filesystem) unsafeFilePath(p string) string {
|
|
// Calling filepath.Clean on the joined directory will resolve it to the absolute path,
|
|
// removing any ../ type of resolution arguments, and leaving us with a direct path link.
|
|
//
|
|
// This will also trim the existing root path off the beginning of the path passed to
|
|
// the function since that can get a bit messy.
|
|
return filepath.Clean(filepath.Join(fs.Path(), strings.TrimPrefix(p, fs.Path())))
|
|
}
|
|
|
|
// Check that that path string starts with the server data directory path. This function DOES NOT
|
|
// validate that the rest of the path does not end up resolving out of this directory, or that the
|
|
// targeted file or folder is not a symlink doing the same thing.
|
|
func (fs *Filesystem) unsafeIsInDataDirectory(p string) bool {
|
|
return strings.HasPrefix(strings.TrimSuffix(p, "/")+"/", strings.TrimSuffix(fs.Path(), "/")+"/")
|
|
}
|
|
|
|
// Executes the fs.SafePath function in parallel against an array of paths. If any of the calls
|
|
// fails an error will be returned.
|
|
func (fs *Filesystem) ParallelSafePath(paths []string) ([]string, error) {
|
|
var cleaned []string
|
|
|
|
// Simple locker function to avoid racy appends to the array of cleaned paths.
|
|
var m = new(sync.Mutex)
|
|
var push = func(c string) {
|
|
m.Lock()
|
|
cleaned = append(cleaned, c)
|
|
m.Unlock()
|
|
}
|
|
|
|
// Create an error group that we can use to run processes in parallel while retaining
|
|
// the ability to cancel the entire process immediately should any of it fail.
|
|
g, ctx := errgroup.WithContext(context.Background())
|
|
|
|
// Iterate over all of the paths and generate a cleaned path, if there is an error for any
|
|
// of the files, abort the process.
|
|
for _, p := range paths {
|
|
// Create copy so we can use it within the goroutine correctly.
|
|
pi := p
|
|
|
|
// Recursively call this function to continue digging through the directory tree within
|
|
// a separate goroutine. If the context is canceled abort this process.
|
|
g.Go(func() error {
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
default:
|
|
// If the callback returns true, go ahead and keep walking deeper. This allows
|
|
// us to programmatically continue deeper into directories, or stop digging
|
|
// if that pathway knows it needs nothing else.
|
|
if c, err := fs.SafePath(pi); err != nil {
|
|
return err
|
|
} else {
|
|
push(c)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
})
|
|
}
|
|
|
|
// Block until all of the routines finish and have returned a value.
|
|
return cleaned, g.Wait()
|
|
}
|