mirror of
https://github.com/filebrowser/filebrowser.git
synced 2026-07-17 16:36:49 +00:00
292 lines
9.3 KiB
Go
292 lines
9.3 KiB
Go
package fbhttp
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"github.com/asdine/storm/v3"
|
|
"github.com/filebrowser/filebrowser/v2/files"
|
|
"github.com/filebrowser/filebrowser/v2/rules"
|
|
"github.com/filebrowser/filebrowser/v2/settings"
|
|
"github.com/filebrowser/filebrowser/v2/share"
|
|
"github.com/filebrowser/filebrowser/v2/storage/bolt"
|
|
"github.com/filebrowser/filebrowser/v2/users"
|
|
"github.com/spf13/afero"
|
|
)
|
|
|
|
func TestPublicShareHandlerAuthentication(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const passwordBcrypt = "$2y$10$TFAmdCbyd/mEZDe5fUeZJu.MaJQXRTwdqb/IQV.eTn6dWrF58gCSe"
|
|
testCases := map[string]struct {
|
|
share *share.Link
|
|
req *http.Request
|
|
sharePerm bool
|
|
downloadPerm bool
|
|
expectedStatusCode int
|
|
}{
|
|
"Public share, no auth required": {
|
|
share: &share.Link{Hash: "h", UserID: 1},
|
|
req: newHTTPRequest(t),
|
|
sharePerm: true,
|
|
downloadPerm: true,
|
|
expectedStatusCode: 200,
|
|
},
|
|
"Private share, no auth provided, 401": {
|
|
share: &share.Link{Hash: "h", UserID: 1, PasswordHash: passwordBcrypt, Token: "123"},
|
|
req: newHTTPRequest(t),
|
|
sharePerm: true,
|
|
downloadPerm: true,
|
|
expectedStatusCode: 401,
|
|
},
|
|
"Private share, authentication via token": {
|
|
share: &share.Link{Hash: "h", UserID: 1, PasswordHash: passwordBcrypt, Token: "123"},
|
|
req: newHTTPRequest(t, func(r *http.Request) { r.URL.RawQuery = "token=123" }),
|
|
sharePerm: true,
|
|
downloadPerm: true,
|
|
expectedStatusCode: 200,
|
|
},
|
|
"Private share, authentication via invalid token, 401": {
|
|
share: &share.Link{Hash: "h", UserID: 1, PasswordHash: passwordBcrypt, Token: "123"},
|
|
req: newHTTPRequest(t, func(r *http.Request) { r.URL.RawQuery = "token=1234" }),
|
|
sharePerm: true,
|
|
downloadPerm: true,
|
|
expectedStatusCode: 401,
|
|
},
|
|
"Private share, authentication via password": {
|
|
share: &share.Link{Hash: "h", UserID: 1, PasswordHash: passwordBcrypt, Token: "123"},
|
|
req: newHTTPRequest(t, func(r *http.Request) { r.Header.Set("X-SHARE-PASSWORD", "password") }),
|
|
sharePerm: true,
|
|
downloadPerm: true,
|
|
expectedStatusCode: 200,
|
|
},
|
|
"Private share, authentication via invalid password, 401": {
|
|
share: &share.Link{Hash: "h", UserID: 1, PasswordHash: passwordBcrypt, Token: "123"},
|
|
req: newHTTPRequest(t, func(r *http.Request) { r.Header.Set("X-SHARE-PASSWORD", "wrong-password") }),
|
|
sharePerm: true,
|
|
downloadPerm: true,
|
|
expectedStatusCode: 401,
|
|
},
|
|
"Share owner lost share permission, 403": {
|
|
share: &share.Link{Hash: "h", UserID: 1},
|
|
req: newHTTPRequest(t),
|
|
sharePerm: false,
|
|
downloadPerm: true,
|
|
expectedStatusCode: 403,
|
|
},
|
|
"Share owner lost download permission, 403": {
|
|
share: &share.Link{Hash: "h", UserID: 1},
|
|
req: newHTTPRequest(t),
|
|
sharePerm: true,
|
|
downloadPerm: false,
|
|
expectedStatusCode: 403,
|
|
},
|
|
}
|
|
|
|
for name, tc := range testCases {
|
|
for handlerName, handler := range map[string]handleFunc{"public share handler": publicShareHandler, "public dl handler": publicDlHandler} {
|
|
name, tc, handlerName, handler := name, tc, handlerName, handler
|
|
t.Run(fmt.Sprintf("%s: %s", handlerName, name), func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dbPath := filepath.Join(t.TempDir(), "db")
|
|
db, err := storm.Open(dbPath)
|
|
if err != nil {
|
|
t.Fatalf("failed to open db: %v", err)
|
|
}
|
|
|
|
t.Cleanup(func() {
|
|
if err := db.Close(); err != nil {
|
|
t.Errorf("failed to close db: %v", err)
|
|
}
|
|
})
|
|
|
|
storage, err := bolt.NewStorage(db)
|
|
if err != nil {
|
|
t.Fatalf("failed to get storage: %v", err)
|
|
}
|
|
if err := storage.Share.Save(tc.share); err != nil {
|
|
t.Fatalf("failed to save share: %v", err)
|
|
}
|
|
if err := storage.Users.Save(&users.User{
|
|
Username: "username",
|
|
Password: "pw",
|
|
Perm: users.Permissions{
|
|
Share: tc.sharePerm,
|
|
Download: tc.downloadPerm,
|
|
},
|
|
}); err != nil {
|
|
t.Fatalf("failed to save user: %v", err)
|
|
}
|
|
if err := storage.Settings.Save(&settings.Settings{Key: []byte("key")}); err != nil {
|
|
t.Fatalf("failed to save settings: %v", err)
|
|
}
|
|
|
|
storage.Users = &customFSUser{
|
|
Store: storage.Users,
|
|
fs: &afero.MemMapFs{},
|
|
}
|
|
|
|
recorder := httptest.NewRecorder()
|
|
handler := handle(handler, "", storage, &settings.Server{})
|
|
|
|
handler.ServeHTTP(recorder, tc.req)
|
|
result := recorder.Result()
|
|
defer result.Body.Close()
|
|
if result.StatusCode != tc.expectedStatusCode {
|
|
t.Errorf("expected status code %d, got status code %d", tc.expectedStatusCode, result.StatusCode)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestPublicShareHandlerRules ensures that owner rules keep applying to paths
|
|
// below a shared directory, even though the share rebases the filesystem onto
|
|
// that directory. A deny rule relative to the owner's scope must not be
|
|
// bypassable by requesting the blocked path through the public share.
|
|
func TestPublicShareHandlerRules(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
testCases := map[string]struct {
|
|
handler handleFunc
|
|
path string
|
|
expectedStatusCode int
|
|
}{
|
|
"blocked file via dl handler, 403": {
|
|
handler: publicDlHandler,
|
|
path: "h/private/secret.txt",
|
|
expectedStatusCode: 403,
|
|
},
|
|
"blocked dir listing via share handler, 403": {
|
|
handler: publicShareHandler,
|
|
path: "h/private/",
|
|
expectedStatusCode: 403,
|
|
},
|
|
"blocked dir download via dl handler, 403": {
|
|
handler: publicDlHandler,
|
|
path: "h/private/",
|
|
expectedStatusCode: 403,
|
|
},
|
|
"allowed file via dl handler, 200": {
|
|
handler: publicDlHandler,
|
|
path: "h/public/readme.txt",
|
|
expectedStatusCode: 200,
|
|
},
|
|
"allowed dir listing via share handler, 200": {
|
|
handler: publicShareHandler,
|
|
path: "h/public/",
|
|
expectedStatusCode: 200,
|
|
},
|
|
}
|
|
|
|
for name, tc := range testCases {
|
|
name, tc := name, tc
|
|
t.Run(name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dbPath := filepath.Join(t.TempDir(), "db")
|
|
db, err := storm.Open(dbPath)
|
|
if err != nil {
|
|
t.Fatalf("failed to open db: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
if err := db.Close(); err != nil {
|
|
t.Errorf("failed to close db: %v", err)
|
|
}
|
|
})
|
|
|
|
storage, err := bolt.NewStorage(db)
|
|
if err != nil {
|
|
t.Fatalf("failed to get storage: %v", err)
|
|
}
|
|
if err := storage.Share.Save(&share.Link{Hash: "h", UserID: 1, Path: "/projects"}); err != nil {
|
|
t.Fatalf("failed to save share: %v", err)
|
|
}
|
|
if err := storage.Users.Save(&users.User{
|
|
Username: "username",
|
|
Password: "pw",
|
|
Perm: users.Permissions{Share: true, Download: true},
|
|
Rules: []rules.Rule{
|
|
{Allow: false, Path: "/projects/private"},
|
|
},
|
|
}); err != nil {
|
|
t.Fatalf("failed to save user: %v", err)
|
|
}
|
|
if err := storage.Settings.Save(&settings.Settings{Key: []byte("key")}); err != nil {
|
|
t.Fatalf("failed to save settings: %v", err)
|
|
}
|
|
|
|
fs := files.NewScopedFs(afero.NewOsFs(), t.TempDir())
|
|
if err := fs.MkdirAll("/projects/private", 0o755); err != nil {
|
|
t.Fatalf("failed to create private dir: %v", err)
|
|
}
|
|
if err := fs.MkdirAll("/projects/public", 0o755); err != nil {
|
|
t.Fatalf("failed to create public dir: %v", err)
|
|
}
|
|
if err := afero.WriteFile(fs, "/projects/private/secret.txt", []byte("top secret"), 0o600); err != nil {
|
|
t.Fatalf("failed to write secret file: %v", err)
|
|
}
|
|
if err := afero.WriteFile(fs, "/projects/public/readme.txt", []byte("hello"), 0o600); err != nil {
|
|
t.Fatalf("failed to write public file: %v", err)
|
|
}
|
|
|
|
storage.Users = &customFSUser{
|
|
Store: storage.Users,
|
|
fs: fs,
|
|
}
|
|
|
|
req := newHTTPRequest(t, func(r *http.Request) { r.URL.Path = tc.path })
|
|
|
|
recorder := httptest.NewRecorder()
|
|
handler := handle(tc.handler, "", storage, &settings.Server{})
|
|
|
|
handler.ServeHTTP(recorder, req)
|
|
result := recorder.Result()
|
|
defer result.Body.Close()
|
|
if result.StatusCode != tc.expectedStatusCode {
|
|
t.Errorf("expected status code %d, got status code %d", tc.expectedStatusCode, result.StatusCode)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func newHTTPRequest(t *testing.T, requestModifiers ...func(*http.Request)) *http.Request {
|
|
t.Helper()
|
|
r, err := http.NewRequest(http.MethodGet, "h", http.NoBody)
|
|
if err != nil {
|
|
t.Fatalf("failed to construct request: %v", err)
|
|
}
|
|
for _, modify := range requestModifiers {
|
|
modify(r)
|
|
}
|
|
return r
|
|
}
|
|
|
|
type customFSUser struct {
|
|
users.Store
|
|
fs afero.Fs
|
|
// followExternal mirrors Server.FollowExternalSymlinks: when set, the
|
|
// provided fs is used as-is (a bare BasePathFs that follows symlinks);
|
|
// otherwise it is wrapped in a symlink-confining ScopedFs.
|
|
followExternal bool
|
|
}
|
|
|
|
func (cu *customFSUser) Get(baseScope string, followExternalSymlinks bool, id interface{}) (*users.User, error) {
|
|
user, err := cu.Store.Get(baseScope, followExternalSymlinks, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Inject a filesystem rooted at the test's temp scope, standing in for the
|
|
// one users.User.Clean would build in production.
|
|
if cu.followExternal {
|
|
user.Fs = cu.fs
|
|
} else {
|
|
user.Fs = files.NewScopedFs(cu.fs, "/")
|
|
}
|
|
|
|
return user, nil
|
|
}
|