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