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
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))
|
|
}
|
|
|