9 changed files with 577 additions and 28 deletions
@ -0,0 +1,152 @@ |
|||||
|
// 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)) |
||||
|
} |
||||
@ -0,0 +1,64 @@ |
|||||
|
package browser |
||||
|
|
||||
|
import ( |
||||
|
"errors" |
||||
|
"os" |
||||
|
"path/filepath" |
||||
|
"testing" |
||||
|
|
||||
|
"webfilebrowser/internal/config" |
||||
|
) |
||||
|
|
||||
|
func TestListReadsAConfiguredRootAndNestedDirectory(t *testing.T) { |
||||
|
rootPath := t.TempDir() |
||||
|
if err := os.Mkdir(filepath.Join(rootPath, "documents"), 0o755); err != nil { |
||||
|
t.Fatal(err) |
||||
|
} |
||||
|
if err := os.WriteFile(filepath.Join(rootPath, "documents", "report.txt"), []byte("report"), 0o600); err != nil { |
||||
|
t.Fatal(err) |
||||
|
} |
||||
|
|
||||
|
browser, err := New([]config.Root{{Name: "Media", Path: rootPath}}) |
||||
|
if err != nil { |
||||
|
t.Fatalf("New() error = %v", err) |
||||
|
} |
||||
|
|
||||
|
directory, err := browser.List("Media", "documents") |
||||
|
if err != nil { |
||||
|
t.Fatalf("List() error = %v", err) |
||||
|
} |
||||
|
if directory.RelativePath != "documents" { |
||||
|
t.Errorf("List().RelativePath = %q, want %q", directory.RelativePath, "documents") |
||||
|
} |
||||
|
if len(directory.Entries) != 1 { |
||||
|
t.Fatalf("List().Entries length = %d, want 1", len(directory.Entries)) |
||||
|
} |
||||
|
entry := directory.Entries[0] |
||||
|
if entry.Name != "report.txt" || entry.IsDirectory || entry.Size != int64(len("report")) { |
||||
|
t.Errorf("List().Entries[0] = %#v, want report.txt file with its byte size", entry) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func TestListRejectsTraversalAndSymlinkEscapes(t *testing.T) { |
||||
|
rootPath := t.TempDir() |
||||
|
outsidePath := t.TempDir() |
||||
|
if err := os.WriteFile(filepath.Join(outsidePath, "secret.txt"), []byte("outside-root"), 0o600); err != nil { |
||||
|
t.Fatal(err) |
||||
|
} |
||||
|
if err := os.Symlink(outsidePath, filepath.Join(rootPath, "escape")); err != nil { |
||||
|
t.Fatal(err) |
||||
|
} |
||||
|
|
||||
|
browser, err := New([]config.Root{{Name: "Media", Path: rootPath}}) |
||||
|
if err != nil { |
||||
|
t.Fatal(err) |
||||
|
} |
||||
|
|
||||
|
for _, relativePath := range []string{"../" + filepath.Base(outsidePath), "escape"} { |
||||
|
t.Run(relativePath, func(t *testing.T) { |
||||
|
if _, err := browser.List("Media", relativePath); !errors.Is(err, ErrPathOutsideRoot) { |
||||
|
t.Errorf("List(%q) error = %v, want ErrPathOutsideRoot", relativePath, err) |
||||
|
} |
||||
|
}) |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,120 @@ |
|||||
|
package web |
||||
|
|
||||
|
import ( |
||||
|
"net/http" |
||||
|
"net/http/httptest" |
||||
|
"net/url" |
||||
|
"os" |
||||
|
"path/filepath" |
||||
|
"strings" |
||||
|
"testing" |
||||
|
|
||||
|
"webfilebrowser/internal/auth" |
||||
|
"webfilebrowser/internal/config" |
||||
|
) |
||||
|
|
||||
|
func TestAuthenticatedBrowserListsRootsAndProvidesBreadcrumbNavigation(t *testing.T) { |
||||
|
rootPath, handler := newBrowsingApplication(t) |
||||
|
if err := os.Mkdir(filepath.Join(rootPath, "documents"), 0o755); err != nil { |
||||
|
t.Fatal(err) |
||||
|
} |
||||
|
if err := os.WriteFile(filepath.Join(rootPath, "documents", "report.txt"), []byte("report"), 0o600); err != nil { |
||||
|
t.Fatal(err) |
||||
|
} |
||||
|
cookie := completeSeedPasswordChange(t, handler) |
||||
|
|
||||
|
rootResponse := getWithCookie(handler, "/", cookie) |
||||
|
if rootResponse.Code != http.StatusOK { |
||||
|
t.Fatalf("GET / status = %d, want %d", rootResponse.Code, http.StatusOK) |
||||
|
} |
||||
|
if body := rootResponse.Body.String(); !strings.Contains(body, "Media") || !strings.Contains(body, "/browse?root=Media") { |
||||
|
t.Errorf("GET / did not render the configured root: %s", body) |
||||
|
} |
||||
|
|
||||
|
directoryResponse := getWithCookie(handler, "/browse?root=Media&path=documents", cookie) |
||||
|
if directoryResponse.Code != http.StatusOK { |
||||
|
t.Fatalf("GET nested directory status = %d, want %d", directoryResponse.Code, http.StatusOK) |
||||
|
} |
||||
|
body := directoryResponse.Body.String() |
||||
|
for _, text := range []string{"Media", "documents", "report.txt", "File"} { |
||||
|
if !strings.Contains(body, text) { |
||||
|
t.Errorf("GET nested directory body is missing %q: %s", text, body) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func TestBrowserRejectsTraversalAndSymlinkEscapeRequests(t *testing.T) { |
||||
|
rootPath, handler := newBrowsingApplication(t) |
||||
|
outsidePath := t.TempDir() |
||||
|
if err := os.WriteFile(filepath.Join(outsidePath, "secret.txt"), []byte("outside-root"), 0o600); err != nil { |
||||
|
t.Fatal(err) |
||||
|
} |
||||
|
if err := os.Symlink(outsidePath, filepath.Join(rootPath, "escape")); err != nil { |
||||
|
t.Fatal(err) |
||||
|
} |
||||
|
cookie := completeSeedPasswordChange(t, handler) |
||||
|
|
||||
|
for _, target := range []string{"/browse?root=Media&path=../outside", "/browse?root=Media&path=escape"} { |
||||
|
t.Run(target, func(t *testing.T) { |
||||
|
response := getWithCookie(handler, target, cookie) |
||||
|
if response.Code != http.StatusNotFound { |
||||
|
t.Errorf("GET %s status = %d, want %d", target, response.Code, http.StatusNotFound) |
||||
|
} |
||||
|
if strings.Contains(response.Body.String(), "outside-root") { |
||||
|
t.Errorf("GET %s exposed content outside the configured root", target) |
||||
|
} |
||||
|
}) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func TestBrowserThemeTogglePersistsInLocalStorage(t *testing.T) { |
||||
|
_, handler := newBrowsingApplication(t) |
||||
|
cookie := completeSeedPasswordChange(t, handler) |
||||
|
|
||||
|
response := getWithCookie(handler, "/", cookie) |
||||
|
if response.Code != http.StatusOK { |
||||
|
t.Fatalf("GET / status = %d, want %d", response.Code, http.StatusOK) |
||||
|
} |
||||
|
body := response.Body.String() |
||||
|
for _, text := range []string{`id="theme-toggle"`, "localStorage.getItem", "localStorage.setItem", "data-theme"} { |
||||
|
if !strings.Contains(body, text) { |
||||
|
t.Errorf("GET / is missing theme persistence behavior %q", text) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func newBrowsingApplication(t *testing.T) (string, http.Handler) { |
||||
|
t.Helper() |
||||
|
rootPath := t.TempDir() |
||||
|
store, err := auth.Open(filepath.Join(t.TempDir(), "webfilebrowser.db"), auth.Options{}) |
||||
|
if err != nil { |
||||
|
t.Fatalf("Open() error = %v", err) |
||||
|
} |
||||
|
t.Cleanup(func() { _ = store.Close() }) |
||||
|
return rootPath, New(store, config.Root{Name: "Media", Path: rootPath}) |
||||
|
} |
||||
|
|
||||
|
func completeSeedPasswordChange(t *testing.T, handler http.Handler) *http.Cookie { |
||||
|
t.Helper() |
||||
|
loginResponse := postForm(handler, loginPath, url.Values{ |
||||
|
"email": {auth.SeedAdminEmail}, |
||||
|
"password": {auth.SeedAdminPassword}, |
||||
|
}, nil) |
||||
|
cookie := responseCookie(t, loginResponse, sessionCookieName) |
||||
|
changeResponse := postForm(handler, changePasswordPath, url.Values{ |
||||
|
"current_password": {auth.SeedAdminPassword}, |
||||
|
"new_password": {"A-new-password-123!"}, |
||||
|
}, cookie) |
||||
|
if changeResponse.Code != http.StatusSeeOther { |
||||
|
t.Fatalf("POST %s status = %d, want %d", changePasswordPath, changeResponse.Code, http.StatusSeeOther) |
||||
|
} |
||||
|
return cookie |
||||
|
} |
||||
|
|
||||
|
func getWithCookie(handler http.Handler, target string, cookie *http.Cookie) *httptest.ResponseRecorder { |
||||
|
request := httptest.NewRequest(http.MethodGet, target, nil) |
||||
|
request.AddCookie(cookie) |
||||
|
response := httptest.NewRecorder() |
||||
|
handler.ServeHTTP(response, request) |
||||
|
return response |
||||
|
} |
||||
Loading…
Reference in new issue