Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85b7d2762d | ||
|
|
4edabb9ccc | ||
|
|
103683069e | ||
|
|
5328e80d2e | ||
|
|
847d08bdd1 | ||
|
|
0231b7ebdf | ||
|
|
e07c59df0b | ||
|
|
0d3eb9bea9 | ||
|
|
34ae34e764 | ||
|
|
ca0108f070 |
@@ -2,6 +2,17 @@
|
||||
|
||||
All notable changes to this project will be documented in this file. See [commit-and-tag-version](https://github.com/absolute-version/commit-and-tag-version) for commit guidelines.
|
||||
|
||||
## [2.63.6](https://github.com/filebrowser/filebrowser/compare/v2.63.5...v2.63.6) (2026-06-03)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* address three security disclosures (archive traversal, login DoS, symlink escape) ([847d08b](https://github.com/filebrowser/filebrowser/commit/847d08bdd135e5c3659f2e6dea2f0cd36617af9b))
|
||||
* cross-user unauthorized share-link deletion ([0231b7e](https://github.com/filebrowser/filebrowser/commit/0231b7ebdfbe77a6c54027d30c4856c3fd81ee4d))
|
||||
* incorrect access control in public directory shares via rule path rebasing ([e07c59d](https://github.com/filebrowser/filebrowser/commit/e07c59df0b850f5924d5b1683e8609661ddcf534))
|
||||
* parse csv files with uneven columns in their rows ([#5965](https://github.com/filebrowser/filebrowser/issues/5965)) ([5328e80](https://github.com/filebrowser/filebrowser/commit/5328e80d2e88d1c279a1250a7dfee4fc96f703ec))
|
||||
* remove undocumented hook auth with shell replacement ([34ae34e](https://github.com/filebrowser/filebrowser/commit/34ae34e764d72540c039f1f5ea2ec4c974168c1f))
|
||||
|
||||
## [2.63.5](https://github.com/filebrowser/filebrowser/compare/v2.63.4...v2.63.5) (2026-05-21)
|
||||
|
||||
|
||||
|
||||
@@ -86,22 +86,6 @@ func (a *HookAuth) LoginPage() bool {
|
||||
// RunCommand starts the hook command and returns the action
|
||||
func (a *HookAuth) RunCommand() (string, error) {
|
||||
command := strings.Split(a.Command, " ")
|
||||
envMapping := func(key string) string {
|
||||
switch key {
|
||||
case "USERNAME":
|
||||
return a.Cred.Username
|
||||
case "PASSWORD":
|
||||
return a.Cred.Password
|
||||
default:
|
||||
return os.Getenv(key)
|
||||
}
|
||||
}
|
||||
for i, arg := range command {
|
||||
if i == 0 {
|
||||
continue
|
||||
}
|
||||
command[i] = os.Expand(arg, envMapping)
|
||||
}
|
||||
|
||||
cmd := exec.Command(command[0], command[1:]...)
|
||||
cmd.Env = append(os.Environ(), fmt.Sprintf("USERNAME=%s", a.Cred.Username))
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// writeHookScript writes a POSIX shell script to a temp file and returns its
|
||||
// path, marking it executable.
|
||||
func writeHookScript(t *testing.T, body string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "hook.sh")
|
||||
if err := os.WriteFile(path, []byte("#!/bin/sh\n"+body), 0o700); err != nil {
|
||||
t.Fatalf("failed to write hook script: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// TestRunCommandNoCredentialInjection ensures that attacker-controlled
|
||||
// credentials submitted at the unauthenticated login endpoint cannot be
|
||||
// injected into the hook command string. Credentials must only ever reach the
|
||||
// hook through the USERNAME/PASSWORD environment variables, never via string
|
||||
// substitution into the command itself (CWE-78/CWE-88).
|
||||
func TestRunCommandNoCredentialInjection(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("uses POSIX shell")
|
||||
}
|
||||
|
||||
marker := filepath.Join(t.TempDir(), "pwned")
|
||||
|
||||
// The hook simply blocks. If the credential were ever interpolated into the
|
||||
// command string and evaluated by a shell, the embedded `touch` would
|
||||
// create the marker file.
|
||||
script := writeHookScript(t, "echo hook.action=block\n")
|
||||
|
||||
a := &HookAuth{
|
||||
Command: script,
|
||||
Cred: hookCred{
|
||||
Username: `"; touch ` + marker + `; #`,
|
||||
Password: `$(touch ` + marker + `)`,
|
||||
},
|
||||
}
|
||||
|
||||
action, err := a.RunCommand()
|
||||
if err != nil {
|
||||
t.Fatalf("RunCommand returned error: %v", err)
|
||||
}
|
||||
if action != "block" {
|
||||
t.Fatalf("expected action %q, got %q", "block", action)
|
||||
}
|
||||
if _, err := os.Stat(marker); err == nil {
|
||||
t.Fatalf("credential injection executed: marker file %q was created", marker)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunCommandReceivesCredentialsViaEnv verifies the supported contract: the
|
||||
// hook receives credentials through the USERNAME and PASSWORD environment
|
||||
// variables.
|
||||
func TestRunCommandReceivesCredentialsViaEnv(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("uses POSIX shell")
|
||||
}
|
||||
|
||||
script := writeHookScript(t, `if [ "$USERNAME" = alice ] && [ "$PASSWORD" = secret ]; then
|
||||
echo hook.action=auth
|
||||
else
|
||||
echo hook.action=block
|
||||
fi
|
||||
`)
|
||||
|
||||
a := &HookAuth{
|
||||
Command: script,
|
||||
Cred: hookCred{
|
||||
Username: "alice",
|
||||
Password: "secret",
|
||||
},
|
||||
}
|
||||
|
||||
action, err := a.RunCommand()
|
||||
if err != nil {
|
||||
t.Fatalf("RunCommand returned error: %v", err)
|
||||
}
|
||||
if action != "auth" {
|
||||
t.Fatalf("expected action %q, got %q", "auth", action)
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -80,7 +80,7 @@ func addUserFlags(flags *pflag.FlagSet) {
|
||||
flags.Bool("singleClick", false, "use single clicks only")
|
||||
flags.Bool("redirectAfterCopyMove", false, "redirect to destination after copy/move")
|
||||
flags.Bool("dateFormat", false, "use date format (true for absolute time, false for relative)")
|
||||
flags.Bool("hideDotfiles", false, "hide dotfiles")
|
||||
flags.Bool("hideDotfiles", false, "hide dotfiles in file listings")
|
||||
flags.String("aceEditorTheme", "", "ace editor's syntax highlighting theme for users")
|
||||
}
|
||||
|
||||
|
||||
@@ -133,6 +133,17 @@ func stat(opts *FileOptions) (*FileInfo, error) {
|
||||
return file, nil
|
||||
}
|
||||
|
||||
// The path is a symlink. Refuse to follow it if its on-disk target escapes
|
||||
// the user's scoped root; otherwise a symlink that lives lexically inside
|
||||
// the scope but points outside it would let a restricted user read, write,
|
||||
// or share files beyond their boundary.
|
||||
if file != nil && file.IsSymlink {
|
||||
ok, scopeErr := WithinScope(opts.Fs, opts.Path)
|
||||
if scopeErr != nil || !ok {
|
||||
return nil, os.ErrPermission
|
||||
}
|
||||
}
|
||||
|
||||
// fs doesn't support afero.Lstater interface or the file is a symlink
|
||||
info, err := opts.Fs.Stat(opts.Path)
|
||||
if err != nil {
|
||||
@@ -165,6 +176,50 @@ func stat(opts *FileOptions) (*FileInfo, error) {
|
||||
return file, nil
|
||||
}
|
||||
|
||||
// WithinScope reports whether the on-disk target of p — after resolving any
|
||||
// symbolic links — stays within the scoped root of fsys. It exists to stop a
|
||||
// symlink that lives lexically inside a user's scope but points outside it
|
||||
// from being followed for reads, writes, or shares.
|
||||
//
|
||||
// Paths that do not exist yet (e.g. a brand-new file being created) are
|
||||
// validated against their nearest existing ancestor, so legitimate new files
|
||||
// are always allowed. For a filesystem that is not scoped with BasePathFs the
|
||||
// check is a no-op and returns true.
|
||||
//
|
||||
// Note: a dangling symlink whose target does not yet exist resolves to its
|
||||
// containing directory and is therefore allowed; writing through such a link
|
||||
// could still create a file outside the scope. Callers that create files
|
||||
// should treat this as best-effort and rely on rejecting existing escaping
|
||||
// symlinks, which covers the disclosure and overwrite vectors.
|
||||
func WithinScope(fsys afero.Fs, p string) (bool, error) {
|
||||
bfs, ok := fsys.(*afero.BasePathFs)
|
||||
if !ok {
|
||||
// Not a scoped filesystem; nothing to enforce.
|
||||
return true, nil
|
||||
}
|
||||
|
||||
root, err := filepath.EvalSymlinks(afero.FullBaseFsPath(bfs, "/"))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
target := afero.FullBaseFsPath(bfs, p)
|
||||
resolved, err := filepath.EvalSymlinks(target)
|
||||
for errors.Is(err, fs.ErrNotExist) {
|
||||
parent := filepath.Dir(target)
|
||||
if parent == target {
|
||||
break
|
||||
}
|
||||
target = parent
|
||||
resolved, err = filepath.EvalSymlinks(target)
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return resolved == root || strings.HasPrefix(resolved, root+string(filepath.Separator)), nil
|
||||
}
|
||||
|
||||
// Checksum checksums a given File for a given User, using a specific
|
||||
// algorithm. The checksums data is saved on File object.
|
||||
func (i *FileInfo) Checksum(algo string) error {
|
||||
|
||||
@@ -138,7 +138,11 @@ watchEffect(() => {
|
||||
: props.content;
|
||||
parse(
|
||||
content as string,
|
||||
{ delimiter: columnSeparator.value, skip_empty_lines: true },
|
||||
{
|
||||
delimiter: columnSeparator.value,
|
||||
skip_empty_lines: true,
|
||||
relax_column_count: true,
|
||||
},
|
||||
(error, output) => {
|
||||
if (error) {
|
||||
console.error("Failed to parse CSV:", error);
|
||||
|
||||
@@ -29,8 +29,8 @@
|
||||
"rename": "Renommer",
|
||||
"replace": "Remplacer",
|
||||
"reportIssue": "Signaler un problème",
|
||||
"resumeTransfer": "Resume previous transfer",
|
||||
"resumeTransferTooltip": "Skip all conflicting files, except the ones that are smaller on the server as we suppose that their transfert has been interrupted.",
|
||||
"resumeTransfer": "Reprendre le transfert précédent",
|
||||
"resumeTransferTooltip": "Ignorez tous les fichiers en conflit, à l'exception de ceux dont la taille est inférieure sur le serveur, car nous supposons que leur transfert a été interrompu.",
|
||||
"save": "Enregistrer",
|
||||
"schedule": "Planifier",
|
||||
"search": "Rechercher",
|
||||
@@ -247,7 +247,7 @@
|
||||
"execute": "Exécuter des commandes",
|
||||
"modify": "Modifier des fichiers",
|
||||
"rename": "Renommer ou déplacer des fichiers ou des dossiers",
|
||||
"share": "Share files (require download permission)"
|
||||
"share": "Partager des fichiers (autorisation de téléchargement requise)"
|
||||
},
|
||||
"permissions": "Permissions",
|
||||
"permissionsHelp": "Vous pouvez définir l'utilisateur comme étant un administrateur ou encore choisir les permissions individuellement. Si vous sélectionnez \"Administrateur\", toutes les autres options seront automatiquement activées. La gestion des utilisateurs est un privilège que seul l'administrateur possède.\n",
|
||||
@@ -283,7 +283,7 @@
|
||||
"currentPassword": "Mot de Passe Actuel"
|
||||
},
|
||||
"sidebar": {
|
||||
"diskUsed": "{used} of {total} used",
|
||||
"diskUsed": "{used} sur {total} utilisés",
|
||||
"help": "Aide",
|
||||
"hugoNew": "Nouveau Hugo",
|
||||
"login": "Login",
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"rename": "Hernoemen",
|
||||
"replace": "Vervangen",
|
||||
"reportIssue": "Probleem melden",
|
||||
"resumeTransfer": "Resume previous transfer",
|
||||
"resumeTransfer": "Vorige overdracht hervatten",
|
||||
"resumeTransferTooltip": "Alle conflicterende bestanden overslaan, behalve de bestanden die kleiner zijn op de server, omdat we veronderstellen dat hun overdracht is onderbroken.",
|
||||
"save": "Opslaan",
|
||||
"schedule": "Plannen",
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"rename": "Zmień nazwę",
|
||||
"replace": "Zamień",
|
||||
"reportIssue": "Zgłoś problem",
|
||||
"resumeTransfer": "Resume previous transfer",
|
||||
"resumeTransfer": "Wznów poprzedni transfer",
|
||||
"resumeTransferTooltip": "Pomiń wszystkie pliki powodujące konflikty, z wyjątkiem tych, które są mniejsze na serwerze, ponieważ podejrzewamy, że ich transfer został przerwany.",
|
||||
"save": "Zapisz",
|
||||
"schedule": "Harmonogram",
|
||||
|
||||
+9
-1
@@ -20,6 +20,8 @@ import (
|
||||
|
||||
const (
|
||||
DefaultTokenExpirationTime = time.Hour * 2
|
||||
|
||||
maxAuthBodySize = 1 << 20 // 1 MiB
|
||||
)
|
||||
|
||||
type userInfo struct {
|
||||
@@ -120,6 +122,10 @@ func withAdmin(fn handleFunc) handleFunc {
|
||||
|
||||
func loginHandler(tokenExpireTime time.Duration) handleFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
||||
if r.Body != nil {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxAuthBodySize)
|
||||
}
|
||||
|
||||
auther, err := d.store.Auth.Get(d.settings.AuthMethod)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
@@ -142,7 +148,7 @@ type signupBody struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
var signupHandler = func(_ http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
||||
var signupHandler = func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
||||
if !d.settings.Signup {
|
||||
return http.StatusMethodNotAllowed, nil
|
||||
}
|
||||
@@ -151,6 +157,8 @@ var signupHandler = func(_ http.ResponseWriter, r *http.Request, d *data) (int,
|
||||
return http.StatusBadRequest, nil
|
||||
}
|
||||
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxAuthBodySize)
|
||||
|
||||
info := &signupBody{}
|
||||
err := json.NewDecoder(r.Body).Decode(info)
|
||||
if err != nil {
|
||||
|
||||
@@ -3,6 +3,7 @@ package fbhttp
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
gopath "path"
|
||||
"strconv"
|
||||
|
||||
"github.com/tomasen/realip"
|
||||
@@ -23,10 +24,25 @@ type data struct {
|
||||
store *storage.Storage
|
||||
user *users.User
|
||||
raw interface{}
|
||||
|
||||
// checkerPrefix is prepended to every path before evaluating rules. It is
|
||||
// set when the user's filesystem has been rebased onto a subdirectory (as
|
||||
// done for public shares), so that rules — which are relative to the user's
|
||||
// original scope — are still matched against the real path instead of the
|
||||
// rebased one. Empty for regular requests.
|
||||
checkerPrefix string
|
||||
}
|
||||
|
||||
// Check implements rules.Checker.
|
||||
func (d *data) Check(path string) bool {
|
||||
// When the filesystem has been rebased (e.g. a public share rooted at a
|
||||
// subdirectory), the incoming path is relative to that root. Resolve it
|
||||
// back to the user's original scope before matching rules, otherwise rules
|
||||
// targeting paths below the share root would be silently bypassed.
|
||||
if d.checkerPrefix != "" {
|
||||
path = gopath.Join(d.checkerPrefix, path)
|
||||
}
|
||||
|
||||
if d.user.HideDotfiles && rules.MatchHidden(path) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -67,6 +67,11 @@ var withHashFile = func(fn handleFunc) handleFunc {
|
||||
// set fs root to the shared file/folder
|
||||
d.user.Fs = afero.NewBasePathFs(d.user.Fs, basePath)
|
||||
|
||||
// the filesystem is now rebased onto basePath, so paths handed to the
|
||||
// rule checker are relative to it. Resolve them back to the user's
|
||||
// original scope so deny rules below the share root keep applying.
|
||||
d.checkerPrefix = basePath
|
||||
|
||||
file, err = files.NewFileInfo(&files.FileOptions{
|
||||
Fs: d.user.Fs,
|
||||
Path: filePath,
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/asdine/storm/v3"
|
||||
"github.com/spf13/afero"
|
||||
|
||||
"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"
|
||||
@@ -143,6 +144,110 @@ func TestPublicShareHandlerAuthentication(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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 := afero.NewMemMapFs()
|
||||
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)
|
||||
|
||||
@@ -125,6 +125,12 @@ func getFiles(d *data, path, commonPath string) ([]archives.FileInfo, error) {
|
||||
nameInArchive := strings.TrimPrefix(path, commonPath)
|
||||
nameInArchive = strings.TrimPrefix(nameInArchive, string(filepath.Separator))
|
||||
nameInArchive = filepath.ToSlash(nameInArchive)
|
||||
// filepath.ToSlash only rewrites the host separator, so on a Linux
|
||||
// host a stored backslash survives and is emitted verbatim into the
|
||||
// archive. Windows extractors then treat "\" as a path separator,
|
||||
// allowing the entry to escape the extraction directory (zip-slip).
|
||||
// Strip Windows separators regardless of host OS.
|
||||
nameInArchive = strings.ReplaceAll(nameInArchive, "\\", "/")
|
||||
|
||||
archiveFiles = append(archiveFiles, archives.FileInfo{
|
||||
FileInfo: info,
|
||||
|
||||
+8
-1
@@ -100,7 +100,7 @@ func resourceDeleteHandler(fileCache FileCache) handleFunc {
|
||||
return errToStatus(err), err
|
||||
}
|
||||
|
||||
err = d.store.Share.DeleteWithPathPrefix(file.Path)
|
||||
err = d.store.Share.DeleteWithPathPrefix(file.Path, d.user.ID)
|
||||
if err != nil {
|
||||
log.Printf("WARNING: Error(s) occurred while deleting associated shares with file: %s", err)
|
||||
}
|
||||
@@ -295,6 +295,13 @@ func addVersionSuffix(source string, afs afero.Fs) string {
|
||||
}
|
||||
|
||||
func writeFile(afs afero.Fs, dst string, in io.Reader, fileMode, dirMode fs.FileMode) (os.FileInfo, error) {
|
||||
// Refuse to write through a symlink that escapes the user's scope, so an
|
||||
// overwrite of an existing escaping symlink cannot modify a file outside
|
||||
// the boundary.
|
||||
if ok, err := files.WithinScope(afs, dst); err != nil || !ok {
|
||||
return nil, os.ErrPermission
|
||||
}
|
||||
|
||||
dir, _ := path.Split(dst)
|
||||
err := afs.MkdirAll(dir, dirMode)
|
||||
if err != nil {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"group:allNonMajor",
|
||||
"group:allDigest",
|
||||
":disableDependencyDashboard",
|
||||
":disableMajorUpdates",
|
||||
":semanticCommitTypeAll(chore)"
|
||||
],
|
||||
"postUpdateOptions": [
|
||||
|
||||
+3
-3
@@ -15,7 +15,7 @@ type StorageBackend interface {
|
||||
Gets(path string, id uint) ([]*Link, error)
|
||||
Save(s *Link) error
|
||||
Delete(hash string) error
|
||||
DeleteWithPathPrefix(path string) error
|
||||
DeleteWithPathPrefix(path string, userID uint) error
|
||||
}
|
||||
|
||||
// Storage is a storage.
|
||||
@@ -137,6 +137,6 @@ func (s *Storage) Delete(hash string) error {
|
||||
return s.back.Delete(hash)
|
||||
}
|
||||
|
||||
func (s *Storage) DeleteWithPathPrefix(path string) error {
|
||||
return s.back.DeleteWithPathPrefix(path)
|
||||
func (s *Storage) DeleteWithPathPrefix(path string, userID uint) error {
|
||||
return s.back.DeleteWithPathPrefix(path, userID)
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ func (f fakeBackend) Delete(_ string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f fakeBackend) DeleteWithPathPrefix(_ string) error {
|
||||
func (f fakeBackend) DeleteWithPathPrefix(_ string, _ uint) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
+15
-1
@@ -2,6 +2,7 @@ package bolt
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/asdine/storm/v3"
|
||||
"github.com/asdine/storm/v3/q"
|
||||
@@ -76,14 +77,27 @@ func (s shareBackend) Delete(hash string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func (s shareBackend) DeleteWithPathPrefix(pathPrefix string) error {
|
||||
func (s shareBackend) DeleteWithPathPrefix(pathPrefix string, userID uint) error {
|
||||
var links []share.Link
|
||||
if err := s.db.Prefix("Path", pathPrefix, &links); err != nil {
|
||||
if errors.Is(err, storm.ErrNotFound) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
prefix := strings.TrimRight(pathPrefix, "/")
|
||||
|
||||
var err error
|
||||
for _, link := range links {
|
||||
if link.UserID != userID {
|
||||
continue
|
||||
}
|
||||
|
||||
if link.Path != prefix && !strings.HasPrefix(link.Path, prefix+"/") {
|
||||
continue
|
||||
}
|
||||
|
||||
err = errors.Join(err, s.db.DeleteStruct(&share.Link{Hash: link.Hash}))
|
||||
}
|
||||
return err
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package bolt
|
||||
|
||||
import (
|
||||
"os"
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"github.com/asdine/storm/v3"
|
||||
|
||||
"github.com/filebrowser/filebrowser/v2/share"
|
||||
)
|
||||
|
||||
func newTestShareBackend(t *testing.T) shareBackend {
|
||||
t.Helper()
|
||||
|
||||
f, err := os.CreateTemp(t.TempDir(), "shares-*.db")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp db: %v", err)
|
||||
}
|
||||
_ = f.Close()
|
||||
|
||||
db, err := storm.Open(f.Name())
|
||||
if err != nil {
|
||||
t.Fatalf("failed to open db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
|
||||
return shareBackend{db: db}
|
||||
}
|
||||
|
||||
func remainingHashes(t *testing.T, s shareBackend) []string {
|
||||
t.Helper()
|
||||
|
||||
links, err := s.All()
|
||||
if err != nil {
|
||||
t.Fatalf("All returned error: %v", err)
|
||||
}
|
||||
|
||||
hashes := make([]string, 0, len(links))
|
||||
for _, link := range links {
|
||||
hashes = append(hashes, link.Hash)
|
||||
}
|
||||
sort.Strings(hashes)
|
||||
return hashes
|
||||
}
|
||||
|
||||
func TestDeleteWithPathPrefix(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := newTestShareBackend(t)
|
||||
|
||||
links := []*share.Link{
|
||||
// user 1's links
|
||||
{Hash: "u1-a", Path: "/a", UserID: 1},
|
||||
{Hash: "u1-a-child", Path: "/a/child.txt", UserID: 1},
|
||||
{Hash: "u1-abc", Path: "/abc", UserID: 1}, // not a descendant of /a
|
||||
{Hash: "u1-other", Path: "/other", UserID: 1},
|
||||
// user 2's links — must never be touched when user 1 deletes
|
||||
{Hash: "u2-a", Path: "/a", UserID: 2},
|
||||
{Hash: "u2-a-child", Path: "/a/child.txt", UserID: 2},
|
||||
}
|
||||
for _, l := range links {
|
||||
if err := s.Save(l); err != nil {
|
||||
t.Fatalf("failed to save link %s: %v", l.Hash, err)
|
||||
}
|
||||
}
|
||||
|
||||
// User 1 deletes their directory /a. Only user 1's /a and its descendants
|
||||
// should be removed; /abc (sibling sharing a byte prefix) and all of user
|
||||
// 2's links must remain.
|
||||
if err := s.DeleteWithPathPrefix("/a", 1); err != nil {
|
||||
t.Fatalf("DeleteWithPathPrefix returned error: %v", err)
|
||||
}
|
||||
|
||||
got := remainingHashes(t, s)
|
||||
want := []string{"u1-abc", "u1-other", "u2-a", "u2-a-child"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("remaining hashes = %v, want %v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("remaining hashes = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteWithPathPrefixNoMatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := newTestShareBackend(t)
|
||||
|
||||
// No links exist at all: the storm Prefix query returns ErrNotFound, which
|
||||
// must be treated as a no-op rather than surfaced as an error.
|
||||
if err := s.DeleteWithPathPrefix("/a", 1); err != nil {
|
||||
t.Fatalf("DeleteWithPathPrefix on empty store returned error: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -42,7 +42,7 @@ filebrowser config init [flags]
|
||||
--disableTypeDetectionByHeader disables type detection by reading file headers
|
||||
--fileMode string mode bits that new files are created with (default "0o640")
|
||||
-h, --help help for init
|
||||
--hideDotfiles hide dotfiles
|
||||
--hideDotfiles hide dotfiles in file listings
|
||||
--hideLoginButton hide login button from public pages
|
||||
-k, --key string tls key
|
||||
--locale string locale for users (default "en")
|
||||
|
||||
@@ -39,7 +39,7 @@ filebrowser config set [flags]
|
||||
--disableTypeDetectionByHeader disables type detection by reading file headers
|
||||
--fileMode string mode bits that new files are created with (default "0o640")
|
||||
-h, --help help for set
|
||||
--hideDotfiles hide dotfiles
|
||||
--hideDotfiles hide dotfiles in file listings
|
||||
--hideLoginButton hide login button from public pages
|
||||
-k, --key string tls key
|
||||
--locale string locale for users (default "en")
|
||||
|
||||
@@ -17,7 +17,7 @@ filebrowser users add <username> <password> [flags]
|
||||
--commands strings a list of the commands a user can execute
|
||||
--dateFormat use date format (true for absolute time, false for relative)
|
||||
-h, --help help for add
|
||||
--hideDotfiles hide dotfiles
|
||||
--hideDotfiles hide dotfiles in file listings
|
||||
--locale string locale for users (default "en")
|
||||
--lockPassword lock password
|
||||
--perm.admin admin perm for users
|
||||
|
||||
@@ -18,7 +18,7 @@ filebrowser users update <id|username> [flags]
|
||||
--commands strings a list of the commands a user can execute
|
||||
--dateFormat use date format (true for absolute time, false for relative)
|
||||
-h, --help help for update
|
||||
--hideDotfiles hide dotfiles
|
||||
--hideDotfiles hide dotfiles in file listings
|
||||
--locale string locale for users (default "en")
|
||||
--lockPassword lock password
|
||||
-p, --password string new password
|
||||
|
||||
Reference in New Issue
Block a user