You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

152 lines
4.3 KiB

// Package browser safely lists directories within configured filesystem roots.
package browser
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"webfilebrowser/internal/config"
)
var (
// ErrRootNotFound indicates that a requested configured root does not exist.
ErrRootNotFound = errors.New("configured root not found")
// ErrPathOutsideRoot indicates traversal or a symlink that leaves a root.
ErrPathOutsideRoot = errors.New("path is outside the configured root")
// ErrDirectoryNotFound indicates that an allowed path is not a directory.
ErrDirectoryNotFound = errors.New("directory not found")
)
// Entry describes a safely listed file or directory.
type Entry struct {
Name string
IsDirectory bool
Size int64
ModifiedAt time.Time
}
// Directory is a configured root and a directory path within it.
type Directory struct {
Root config.Root
RelativePath string
Entries []Entry
}
// Browser provides root-confined directory listings.
type Browser struct {
rootsByName map[string]config.Root
roots []config.Root
}
// New prepares a browser for the supplied configured roots.
func New(roots []config.Root) (*Browser, error) {
browser := &Browser{
rootsByName: make(map[string]config.Root, len(roots)),
roots: append([]config.Root(nil), roots...),
}
for _, root := range roots {
if _, exists := browser.rootsByName[root.Name]; exists {
return nil, fmt.Errorf("duplicate configured root %q", root.Name)
}
browser.rootsByName[root.Name] = root
}
return browser, nil
}
// Roots returns configured roots in their configuration order.
func (browser *Browser) Roots() []config.Root {
return append([]config.Root(nil), browser.roots...)
}
// List returns the entries in a configured root directory. The requested path
// cannot escape a root through parent-directory traversal or symlinks.
func (browser *Browser) List(rootName, relativePath string) (Directory, error) {
root, exists := browser.rootsByName[rootName]
if !exists {
return Directory{}, ErrRootNotFound
}
cleanRelativePath, err := cleanRelativePath(relativePath)
if err != nil {
return Directory{}, err
}
resolvedRoot, err := filepath.EvalSymlinks(root.Path)
if err != nil {
return Directory{}, fmt.Errorf("%w: %v", ErrDirectoryNotFound, err)
}
rootInfo, err := os.Stat(resolvedRoot)
if err != nil || !rootInfo.IsDir() {
return Directory{}, ErrDirectoryNotFound
}
candidate := root.Path
if cleanRelativePath != "" {
candidate = filepath.Join(root.Path, filepath.FromSlash(cleanRelativePath))
}
resolvedCandidate, err := filepath.EvalSymlinks(candidate)
if err != nil {
return Directory{}, fmt.Errorf("%w: %v", ErrDirectoryNotFound, err)
}
if !isWithin(resolvedRoot, resolvedCandidate) {
return Directory{}, ErrPathOutsideRoot
}
candidateInfo, err := os.Stat(resolvedCandidate)
if err != nil || !candidateInfo.IsDir() {
return Directory{}, ErrDirectoryNotFound
}
entries, err := os.ReadDir(candidate)
if err != nil {
return Directory{}, fmt.Errorf("read directory: %w", err)
}
listedEntries := make([]Entry, 0, len(entries))
for _, entry := range entries {
entryPath := filepath.Join(candidate, entry.Name())
resolvedEntry, err := filepath.EvalSymlinks(entryPath)
if err != nil || !isWithin(resolvedRoot, resolvedEntry) {
continue
}
info, err := os.Stat(resolvedEntry)
if err != nil {
continue
}
listedEntries = append(listedEntries, Entry{
Name: entry.Name(),
IsDirectory: info.IsDir(),
Size: info.Size(),
ModifiedAt: info.ModTime(),
})
}
return Directory{Root: root, RelativePath: cleanRelativePath, Entries: listedEntries}, nil
}
func cleanRelativePath(relativePath string) (string, error) {
if relativePath == "" {
return "", nil
}
path := filepath.FromSlash(relativePath)
if filepath.IsAbs(path) {
return "", ErrPathOutsideRoot
}
path = filepath.Clean(path)
if path == ".." || strings.HasPrefix(path, ".."+string(filepath.Separator)) {
return "", ErrPathOutsideRoot
}
if path == "." {
return "", nil
}
return filepath.ToSlash(path), nil
}
func isWithin(root, candidate string) bool {
relativePath, err := filepath.Rel(root, candidate)
if err != nil {
return false
}
return relativePath != ".." && !strings.HasPrefix(relativePath, ".."+string(filepath.Separator))
}