8 changed files with 337 additions and 10 deletions
@ -0,0 +1,62 @@ |
|||
package browser |
|||
|
|||
import ( |
|||
"errors" |
|||
"io" |
|||
"os" |
|||
"path/filepath" |
|||
"testing" |
|||
|
|||
"webfilebrowser/internal/config" |
|||
) |
|||
|
|||
func TestOpenFileReturnsOnlyARegularFileWithinTheConfiguredRoot(t *testing.T) { |
|||
rootPath := t.TempDir() |
|||
contents := []byte("byte-identical download") |
|||
if err := os.WriteFile(filepath.Join(rootPath, "report.txt"), contents, 0o600); err != nil { |
|||
t.Fatal(err) |
|||
} |
|||
browser, err := New([]config.Root{{Name: "Media", Path: rootPath}}) |
|||
if err != nil { |
|||
t.Fatal(err) |
|||
} |
|||
|
|||
file, metadata, err := browser.OpenFile("Media", "report.txt") |
|||
if err != nil { |
|||
t.Fatalf("OpenFile() error = %v", err) |
|||
} |
|||
t.Cleanup(func() { _ = file.Close() }) |
|||
actual, err := io.ReadAll(file) |
|||
if err != nil { |
|||
t.Fatal(err) |
|||
} |
|||
if string(actual) != string(contents) { |
|||
t.Errorf("OpenFile() bytes = %q, want %q", actual, contents) |
|||
} |
|||
if metadata.Name != "report.txt" || metadata.Size != int64(len(contents)) { |
|||
t.Errorf("OpenFile() metadata = %#v, want report.txt with its byte size", metadata) |
|||
} |
|||
} |
|||
|
|||
func TestOpenFileRejectsTraversalAndSymlinkEscapes(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(filepath.Join(outsidePath, "secret.txt"), filepath.Join(rootPath, "escape.txt")); 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) + "/secret.txt", "escape.txt"} { |
|||
t.Run(relativePath, func(t *testing.T) { |
|||
if _, _, err := browser.OpenFile("Media", relativePath); !errors.Is(err, ErrPathOutsideRoot) { |
|||
t.Errorf("OpenFile(%q) error = %v, want ErrPathOutsideRoot", relativePath, err) |
|||
} |
|||
}) |
|||
} |
|||
} |
|||
@ -0,0 +1,88 @@ |
|||
package web |
|||
|
|||
import ( |
|||
"bytes" |
|||
"net/http" |
|||
"net/http/httptest" |
|||
"os" |
|||
"path/filepath" |
|||
"strings" |
|||
"testing" |
|||
) |
|||
|
|||
func TestDownloadServesByteIdenticalFileAsAttachment(t *testing.T) { |
|||
rootPath, handler := newBrowsingApplication(t) |
|||
contents := []byte("the exact source bytes\x00\xff") |
|||
if err := os.WriteFile(filepath.Join(rootPath, "report.txt"), contents, 0o600); err != nil { |
|||
t.Fatal(err) |
|||
} |
|||
cookie := completeSeedPasswordChange(t, handler) |
|||
|
|||
response := getWithCookie(handler, "/download?root=Media&path=report.txt", cookie) |
|||
if response.Code != http.StatusOK { |
|||
t.Fatalf("GET /download status = %d, want %d", response.Code, http.StatusOK) |
|||
} |
|||
if !bytes.Equal(response.Body.Bytes(), contents) { |
|||
t.Errorf("GET /download bytes = %q, want %q", response.Body.Bytes(), contents) |
|||
} |
|||
if contentType := response.Header().Get("Content-Type"); !strings.HasPrefix(contentType, "text/plain") { |
|||
t.Errorf("GET /download Content-Type = %q, want text/plain", contentType) |
|||
} |
|||
if disposition := response.Header().Get("Content-Disposition"); !strings.HasPrefix(disposition, "attachment;") || !strings.Contains(disposition, "report.txt") { |
|||
t.Errorf("GET /download Content-Disposition = %q, want attachment filename", disposition) |
|||
} |
|||
} |
|||
|
|||
func TestStreamHonorsVideoRangeRequests(t *testing.T) { |
|||
rootPath, handler := newBrowsingApplication(t) |
|||
contents := []byte("0123456789") |
|||
if err := os.WriteFile(filepath.Join(rootPath, "clip.mp4"), contents, 0o600); err != nil { |
|||
t.Fatal(err) |
|||
} |
|||
cookie := completeSeedPasswordChange(t, handler) |
|||
|
|||
request := getRequestWithCookie("/stream?root=Media&path=clip.mp4", cookie) |
|||
request.Header.Set("Range", "bytes=2-5") |
|||
response := serveRequest(handler, request) |
|||
if response.Code != http.StatusPartialContent { |
|||
t.Fatalf("GET /stream Range status = %d, want %d", response.Code, http.StatusPartialContent) |
|||
} |
|||
if body := response.Body.String(); body != "2345" { |
|||
t.Errorf("GET /stream Range bytes = %q, want %q", body, "2345") |
|||
} |
|||
if contentRange := response.Header().Get("Content-Range"); contentRange != "bytes 2-5/10" { |
|||
t.Errorf("GET /stream Content-Range = %q, want %q", contentRange, "bytes 2-5/10") |
|||
} |
|||
if acceptRanges := response.Header().Get("Accept-Ranges"); acceptRanges != "bytes" { |
|||
t.Errorf("GET /stream Accept-Ranges = %q, want %q", acceptRanges, "bytes") |
|||
} |
|||
if contentType := response.Header().Get("Content-Type"); !strings.HasPrefix(contentType, "video/mp4") { |
|||
t.Errorf("GET /stream Content-Type = %q, want video/mp4", contentType) |
|||
} |
|||
} |
|||
|
|||
func TestFileDeliveryRoutesRequireAuthentication(t *testing.T) { |
|||
_, handler := newBrowsingApplication(t) |
|||
for _, target := range []string{"/download?root=Media&path=report.txt", "/stream?root=Media&path=clip.mp4"} { |
|||
t.Run(target, func(t *testing.T) { |
|||
response := serveRequest(handler, getRequestWithCookie(target, nil)) |
|||
if response.Code != http.StatusSeeOther || response.Header().Get("Location") != loginPath { |
|||
t.Errorf("GET %s status/location = (%d, %q), want (%d, %q)", target, response.Code, response.Header().Get("Location"), http.StatusSeeOther, loginPath) |
|||
} |
|||
}) |
|||
} |
|||
} |
|||
|
|||
func getRequestWithCookie(target string, cookie *http.Cookie) *http.Request { |
|||
request := httptest.NewRequest(http.MethodGet, target, nil) |
|||
if cookie != nil { |
|||
request.AddCookie(cookie) |
|||
} |
|||
return request |
|||
} |
|||
|
|||
func serveRequest(handler http.Handler, request *http.Request) *httptest.ResponseRecorder { |
|||
response := httptest.NewRecorder() |
|||
handler.ServeHTTP(response, request) |
|||
return response |
|||
} |
|||
Loading…
Reference in new issue