VYPR
Medium severity6.8GHSA Advisory· Published Jun 12, 2026· Updated Jun 12, 2026

File Browser: Symlink following lets scoped users read, overwrite, and share files outside their filebrowser scope

CVE-2026-54094

Description

File Browser fails to prevent symbolic link traversal in scoped users, allowing read/write access to files outside the intended scope via symlinks inside the user's directory.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

File Browser fails to prevent symbolic link traversal in scoped users, allowing read/write access to files outside the intended scope via symlinks inside the user's directory.

Vulnerability

File Browser enforces per-user scope using afero.NewBasePathFs(afero.NewOsFs(), scope) in users/users.go, which blocks lexical ../ traversal but does not prevent the HTTP handlers from following symbolic links. Two variants exist: (1) a symlink as the final path component that points outside the scope, and (2) a regular file requested through a symlinked directory. All affected versions prior to v2.63.14 are vulnerable. [1][2]

Exploitation

An attacker with a scoped user account can create or leverage an existing symlink inside their scoped tree that resolves to a location outside the scope. Using standard API endpoints such as GET /api/raw/{path}, POST /api/resources/{path}?override=true, or the TUS resumable upload path (POST /api/tus/{path}?override=true followed by PATCH /api/tus/{path}), they can read, overwrite, or create files outside their scope. For Variant 2, an unauthenticated public-share recipient can also read files behind a symlinked directory. [1][2]

Impact

Successful exploitation allows a scoped user to read out-of-scope file contents and metadata, overwrite or create files outside their scope, and create public shares exposing out-of-scope files. This constitutes a breach of the intended access control boundary in multi-user deployments. [1][2]

Mitigation

The vulnerability is fixed in File Browser v2.63.14, released on 2026-06-12. The fix introduces a ScopedFs wrapper that resolves symlinks and checks the target path against the scope before allowing access. Users should upgrade to v2.63.14 or later. No workaround is documented. [3][4]

AI Insight generated on Jun 12, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected products

1

Patches

1
7c2c0a11b31b

refactor: ScopedFs to avoid escaping symlinks

https://github.com/filebrowser/filebrowserHenrique DiasJun 7, 2026via ghsa-ref
15 files changed · +276 209
  • files/file.go+12 70 modified
    @@ -22,10 +22,9 @@ import (
     	"strings"
     	"time"
     
    -	"github.com/spf13/afero"
    -
     	fberrors "github.com/filebrowser/filebrowser/v2/errors"
     	"github.com/filebrowser/filebrowser/v2/rules"
    +	"github.com/spf13/afero"
     )
     
     var (
    @@ -129,13 +128,6 @@ func stat(opts *FileOptions) (*FileInfo, error) {
     		}
     	}
     
    -	if file != nil {
    -		ok, scopeErr := WithinScope(opts.Fs, opts.Path)
    -		if scopeErr != nil || !ok {
    -			return nil, os.ErrPermission
    -		}
    -	}
    -
     	// regular file
     	if file != nil && !file.IsSymlink {
     		return file, nil
    @@ -173,60 +165,6 @@ 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
    -	}
    -
    -	// Compare against root with a trailing separator so a sibling like
    -	// "/srvother" is not treated as being inside "/srv". When root is itself
    -	// the filesystem boundary (e.g. "/"), it already ends in a separator, so
    -	// avoid producing "//" — which no path would match — and accept any path
    -	// under it.
    -	prefix := root
    -	if !strings.HasSuffix(prefix, string(filepath.Separator)) {
    -		prefix += string(filepath.Separator)
    -	}
    -
    -	return resolved == root || strings.HasPrefix(resolved, prefix), 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 {
    @@ -475,15 +413,19 @@ func (i *FileInfo) readListing(checker rules.Checker, readHeader bool, calcImgRe
     		isSymlink, isInvalidLink := false, false
     		if IsSymlink(f.Mode()) {
     			isSymlink = true
    -			if ok, scopeErr := WithinScope(i.Fs, fPath); scopeErr != nil || !ok {
    -				continue
    -			}
    -			// It's a symbolic link. We try to follow it. If it doesn't work,
    -			// we stay with the link information instead of the target's.
    +			// It's a symbolic link. We try to follow it. The scoped filesystem
    +			// refuses to dereference a link whose target escapes the scope
    +			// (permission error); such a link is omitted from the listing
    +			// entirely so it cannot leak the target's metadata. Any other
    +			// failure means a broken link, which we surface as an invalid link
    +			// rather than the target's information.
     			info, err := i.Fs.Stat(fPath)
    -			if err == nil {
    +			switch {
    +			case err == nil:
     				f = info
    -			} else {
    +			case errors.Is(err, os.ErrPermission):
    +				continue
    +			default:
     				isInvalidLink = true
     			}
     		}
    
  • files/file_test.go+31 39 modified
    @@ -9,54 +9,46 @@ import (
     	"github.com/spf13/afero"
     )
     
    -func TestWithinScope(t *testing.T) {
    -	t.Run("non-scoped filesystem is a no-op", func(t *testing.T) {
    -		ok, err := WithinScope(afero.NewOsFs(), "/anything")
    -		if err != nil || !ok {
    -			t.Fatalf("expected (true, nil), got (%v, %v)", ok, err)
    -		}
    -	})
    -
    -	t.Run("path inside a nested scope is allowed", func(t *testing.T) {
    +func TestScopedFs(t *testing.T) {
    +	t.Run("path inside scope is allowed", func(t *testing.T) {
     		scope := t.TempDir()
     		if err := os.WriteFile(filepath.Join(scope, "file.txt"), []byte("x"), 0o644); err != nil {
     			t.Fatal(err)
     		}
    -		bfs := afero.NewBasePathFs(afero.NewOsFs(), scope)
    +		fs := NewScopedFs(afero.NewOsFs(), scope)
     
    -		ok, err := WithinScope(bfs, "/file.txt")
    -		if err != nil || !ok {
    -			t.Fatalf("expected (true, nil), got (%v, %v)", ok, err)
    +		if _, err := fs.Stat("/file.txt"); err != nil {
    +			t.Fatalf("expected in-scope file to be accessible, got %v", err)
     		}
     	})
     
    -	t.Run("new file inside scope is allowed", func(t *testing.T) {
    +	t.Run("new file inside scope can be created", func(t *testing.T) {
     		scope := t.TempDir()
    -		bfs := afero.NewBasePathFs(afero.NewOsFs(), scope)
    +		fs := NewScopedFs(afero.NewOsFs(), scope)
     
    -		ok, err := WithinScope(bfs, "/does-not-exist-yet.txt")
    -		if err != nil || !ok {
    -			t.Fatalf("expected (true, nil), got (%v, %v)", ok, err)
    +		f, err := fs.OpenFile("/does-not-exist-yet.txt", os.O_RDWR|os.O_CREATE, 0o644)
    +		if err != nil {
    +			t.Fatalf("expected to create a new in-scope file, got %v", err)
     		}
    +		_ = f.Close()
     	})
     
     	// Regression for #5975: when the scope resolves to the filesystem root,
     	// root+separator used to be "//", which no path matched, so every write
     	// was rejected with os.ErrPermission (HTTP 403).
    -	t.Run("filesystem root scope allows writes", func(t *testing.T) {
    +	t.Run("filesystem root scope allows access", func(t *testing.T) {
     		f := filepath.Join(t.TempDir(), "file.txt")
     		if err := os.WriteFile(f, []byte("x"), 0o644); err != nil {
     			t.Fatal(err)
     		}
    -		bfs := afero.NewBasePathFs(afero.NewOsFs(), "/")
    +		fs := NewScopedFs(afero.NewOsFs(), "/")
     
    -		ok, err := WithinScope(bfs, f)
    -		if err != nil || !ok {
    -			t.Fatalf("expected (true, nil) for a path under root scope, got (%v, %v)", ok, err)
    +		if _, err := fs.Stat(f); err != nil {
    +			t.Fatalf("expected a path under root scope to be accessible, got %v", err)
     		}
     	})
     
    -	t.Run("sibling of a nested scope is rejected", func(t *testing.T) {
    +	t.Run("escaping symlink to a sibling is rejected", func(t *testing.T) {
     		base := t.TempDir()
     		scope := filepath.Join(base, "srv")
     		sibling := filepath.Join(base, "srvother")
    @@ -65,20 +57,21 @@ func TestWithinScope(t *testing.T) {
     				t.Fatal(err)
     			}
     		}
    -		// A symlink lexically inside the scope pointing at a sibling directory
    -		// must not be followed.
    -		link := filepath.Join(scope, "escape")
    -		if err := os.Symlink(sibling, link); err != nil {
    +		if err := os.WriteFile(filepath.Join(sibling, "secret.txt"), []byte("secret"), 0o644); err != nil {
     			t.Fatal(err)
     		}
    -		bfs := afero.NewBasePathFs(afero.NewOsFs(), scope)
    +		// A symlink lexically inside the scope pointing at a sibling directory
    +		// must not be followed for reads or stats.
    +		if err := os.Symlink(sibling, filepath.Join(scope, "escape")); err != nil {
    +			t.Skipf("cannot create symlink: %v", err)
    +		}
    +		fs := NewScopedFs(afero.NewOsFs(), scope)
     
    -		ok, err := WithinScope(bfs, "/escape")
    -		if err != nil {
    -			t.Fatalf("unexpected error: %v", err)
    +		if _, err := fs.Stat("/escape"); !os.IsPermission(err) {
    +			t.Fatalf("expected stat of escaping symlink to be rejected, got %v", err)
     		}
    -		if ok {
    -			t.Fatal("expected escaping symlink to a sibling directory to be rejected")
    +		if _, err := fs.Open("/escape/secret.txt"); !os.IsPermission(err) {
    +			t.Fatalf("expected read through escaping symlink to be rejected, got %v", err)
     		}
     	})
     
    @@ -93,11 +86,10 @@ func TestWithinScope(t *testing.T) {
     		if err := os.Symlink(filepath.Join(scope, "real"), filepath.Join(scope, "link")); err != nil {
     			t.Skipf("cannot create symlink: %v", err)
     		}
    -		bfs := afero.NewBasePathFs(afero.NewOsFs(), scope)
    +		fs := NewScopedFs(afero.NewOsFs(), scope)
     
    -		ok, err := WithinScope(bfs, "/link/f.txt")
    -		if err != nil || !ok {
    -			t.Fatalf("expected (true, nil) for an in-scope symlink target, got (%v, %v)", ok, err)
    +		if _, err := fs.Stat("/link/f.txt"); err != nil {
    +			t.Fatalf("expected in-scope symlink target to be accessible, got %v", err)
     		}
     	})
     }
    @@ -123,7 +115,7 @@ func TestStatRejectsLinkedAncestorEscape(t *testing.T) {
     	}
     
     	// Filesystem scoped to the shared directory, as a public share would be.
    -	bfs := afero.NewBasePathFs(afero.NewOsFs(), filepath.Join(scope, "shared"))
    +	bfs := NewScopedFs(afero.NewOsFs(), filepath.Join(scope, "shared"))
     
     	if _, err := stat(&FileOptions{Fs: bfs, Path: "/link/secret.txt"}); !os.IsPermission(err) {
     		t.Fatalf("expected permission error for linked-ancestor escape, got %v", err)
    
  • files/scoped.go+184 0 added
    @@ -0,0 +1,184 @@
    +package files
    +
    +import (
    +	"errors"
    +	"io/fs"
    +	"os"
    +	"path/filepath"
    +	"strings"
    +	"time"
    +
    +	"github.com/spf13/afero"
    +)
    +
    +// ScopedFs is an afero.Fs that confines every operation to a base directory and
    +// refuses to follow a symbolic link whose on-disk target resolves outside that
    +// base. It wraps an *afero.BasePathFs — which already provides the lexical
    +// confinement — and adds a per-operation scope check on every call that would
    +// dereference a symlink at the OS layer (open, stat, lstat, chmod, …).
    +type ScopedFs struct {
    +	base *afero.BasePathFs
    +}
    +
    +var (
    +	_ afero.Fs      = (*ScopedFs)(nil)
    +	_ afero.Lstater = (*ScopedFs)(nil)
    +)
    +
    +func NewScopedFs(source afero.Fs, path string) *ScopedFs {
    +	if s, ok := source.(*ScopedFs); ok {
    +		source = s.base
    +	}
    +	return &ScopedFs{base: afero.NewBasePathFs(source, path).(*afero.BasePathFs)}
    +}
    +
    +// Base returns the underlying *afero.BasePathFs.
    +func (s *ScopedFs) Base() *afero.BasePathFs { return s.base }
    +
    +// guard returns an error if name's on-disk target resolves outside the scope.
    +func (s *ScopedFs) guard(name string) error {
    +	ok, err := s.within(name)
    +	if err != nil {
    +		return err
    +	}
    +	if !ok {
    +		return os.ErrPermission
    +	}
    +	return nil
    +}
    +
    +// within reports whether the on-disk target of p — after resolving any symbolic
    +// links — stays within the scoped root. It exists to stop a symlink that lives
    +// lexically inside the 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.
    +//
    +// 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. This is treated as best-effort
    +// and relies on rejecting existing escaping symlinks, which covers the
    +// disclosure and overwrite vectors.
    +func (s *ScopedFs) within(p string) (bool, error) {
    +	root, err := filepath.EvalSymlinks(afero.FullBaseFsPath(s.base, "/"))
    +	if err != nil {
    +		return false, err
    +	}
    +
    +	target := afero.FullBaseFsPath(s.base, 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
    +	}
    +
    +	// Compare against root with a trailing separator so a sibling like
    +	// "/srvother" is not treated as being inside "/srv". When root is itself the
    +	// filesystem boundary (e.g. "/"), it already ends in a separator, so avoid
    +	// producing "//" — which no path would match — and accept any path under it.
    +	prefix := root
    +	if !strings.HasSuffix(prefix, string(filepath.Separator)) {
    +		prefix += string(filepath.Separator)
    +	}
    +
    +	return resolved == root || strings.HasPrefix(resolved, prefix), nil
    +}
    +
    +func (s *ScopedFs) Create(name string) (afero.File, error) {
    +	if err := s.guard(name); err != nil {
    +		return nil, err
    +	}
    +	return s.base.Create(name)
    +}
    +
    +func (s *ScopedFs) Mkdir(name string, perm os.FileMode) error {
    +	if err := s.guard(name); err != nil {
    +		return err
    +	}
    +	return s.base.Mkdir(name, perm)
    +}
    +
    +func (s *ScopedFs) MkdirAll(path string, perm os.FileMode) error {
    +	if err := s.guard(path); err != nil {
    +		return err
    +	}
    +	return s.base.MkdirAll(path, perm)
    +}
    +
    +func (s *ScopedFs) Open(name string) (afero.File, error) {
    +	if err := s.guard(name); err != nil {
    +		return nil, err
    +	}
    +	return s.base.Open(name)
    +}
    +
    +func (s *ScopedFs) OpenFile(name string, flag int, perm os.FileMode) (afero.File, error) {
    +	if err := s.guard(name); err != nil {
    +		return nil, err
    +	}
    +	return s.base.OpenFile(name, flag, perm)
    +}
    +
    +func (s *ScopedFs) Remove(name string) error {
    +	return s.base.Remove(name)
    +}
    +
    +func (s *ScopedFs) RemoveAll(path string) error {
    +	return s.base.RemoveAll(path)
    +}
    +
    +func (s *ScopedFs) Rename(oldname, newname string) error {
    +	if err := s.guard(oldname); err != nil {
    +		return err
    +	}
    +	if err := s.guard(newname); err != nil {
    +		return err
    +	}
    +	return s.base.Rename(oldname, newname)
    +}
    +
    +func (s *ScopedFs) Stat(name string) (os.FileInfo, error) {
    +	if err := s.guard(name); err != nil {
    +		return nil, err
    +	}
    +	return s.base.Stat(name)
    +}
    +
    +func (s *ScopedFs) Name() string { return "ScopedFs" }
    +
    +func (s *ScopedFs) Chmod(name string, mode os.FileMode) error {
    +	if err := s.guard(name); err != nil {
    +		return err
    +	}
    +	return s.base.Chmod(name, mode)
    +}
    +
    +func (s *ScopedFs) Chown(name string, uid, gid int) error {
    +	if err := s.guard(name); err != nil {
    +		return err
    +	}
    +	return s.base.Chown(name, uid, gid)
    +}
    +
    +func (s *ScopedFs) Chtimes(name string, atime, mtime time.Time) error {
    +	if err := s.guard(name); err != nil {
    +		return err
    +	}
    +	return s.base.Chtimes(name, atime, mtime)
    +}
    +
    +func (s *ScopedFs) LstatIfPossible(name string) (os.FileInfo, bool, error) {
    +	if err := s.guard(name); err != nil {
    +		return nil, false, err
    +	}
    +	return s.base.LstatIfPossible(name)
    +}
    
  • fileutils/copy_test.go+3 2 modified
    @@ -5,6 +5,7 @@ import (
     	"path/filepath"
     	"testing"
     
    +	"github.com/filebrowser/filebrowser/v2/files"
     	"github.com/spf13/afero"
     )
     
    @@ -31,7 +32,7 @@ func TestCopyDoesNotDereferenceEscapingSymlink(t *testing.T) {
     		t.Skipf("cannot create symlink: %v", err)
     	}
     
    -	afs := afero.NewBasePathFs(afero.NewOsFs(), scope)
    +	afs := files.NewScopedFs(afero.NewOsFs(), scope)
     
     	err := Copy(afs, "/srcdir", "/dstdir", 0o644, 0o755)
     	if err == nil {
    @@ -58,7 +59,7 @@ func TestCopyAllowsInScopeSymlink(t *testing.T) {
     		t.Skipf("cannot create symlink: %v", err)
     	}
     
    -	afs := afero.NewBasePathFs(afero.NewOsFs(), scope)
    +	afs := files.NewScopedFs(afero.NewOsFs(), scope)
     
     	if err := Copy(afs, "/srcdir", "/dstdir", 0o644, 0o755); err != nil {
     		t.Fatalf("expected copy of an in-scope symlink to succeed, got: %v", err)
    
  • fileutils/dir.go+0 10 modified
    @@ -3,24 +3,14 @@ package fileutils
     import (
     	"errors"
     	"io/fs"
    -	"os"
     
     	"github.com/spf13/afero"
    -
    -	"github.com/filebrowser/filebrowser/v2/files"
     )
     
     // CopyDir copies a directory from source to dest and all
     // of its sub-directories. It doesn't stop if it finds an error
     // during the copy. Returns an error if any.
     func CopyDir(afs afero.Fs, source, dest string, fileMode, dirMode fs.FileMode) error {
    -	if ok, err := files.WithinScope(afs, source); err != nil || !ok {
    -		if err != nil {
    -			return err
    -		}
    -		return os.ErrPermission
    -	}
    -
     	// Get properties of source.
     	srcinfo, err := afs.Stat(source)
     	if err != nil {
    
  • fileutils/file.go+0 9 modified
    @@ -8,8 +8,6 @@ import (
     	"path/filepath"
     
     	"github.com/spf13/afero"
    -
    -	"github.com/filebrowser/filebrowser/v2/files"
     )
     
     // MoveFile moves file from src to dst.
    @@ -34,13 +32,6 @@ func MoveFile(afs afero.Fs, src, dst string, fileMode, dirMode fs.FileMode) erro
     // CopyFile copies a file from source to dest and returns
     // an error if any.
     func CopyFile(afs afero.Fs, source, dest string, fileMode, dirMode fs.FileMode) error {
    -	if ok, err := files.WithinScope(afs, source); err != nil || !ok {
    -		if err != nil {
    -			return err
    -		}
    -		return os.ErrPermission
    -	}
    -
     	// Open the source file.
     	src, err := afs.Open(source)
     	if err != nil {
    
  • http/public.go+6 5 modified
    @@ -9,11 +9,9 @@ import (
     	"path/filepath"
     	"strings"
     
    -	"github.com/spf13/afero"
    -	"golang.org/x/crypto/bcrypt"
    -
     	"github.com/filebrowser/filebrowser/v2/files"
     	"github.com/filebrowser/filebrowser/v2/share"
    +	"golang.org/x/crypto/bcrypt"
     )
     
     var withHashFile = func(fn handleFunc) handleFunc {
    @@ -65,8 +63,11 @@ var withHashFile = func(fn handleFunc) handleFunc {
     			filePath = ifPath
     		}
     
    -		// set fs root to the shared file/folder
    -		d.user.Fs = afero.NewBasePathFs(d.user.Fs, basePath)
    +		// set fs root to the shared file/folder. ScopedFs (not a bare
    +		// BasePathFs) so the share is also symlink-confined: a link inside the
    +		// shared subtree that points elsewhere in the owner's scope — outside
    +		// the share — must not be followed.
    +		d.user.Fs = files.NewScopedFs(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
    
  • http/public_symlink_test.go+3 3 modified
    @@ -12,13 +12,13 @@ import (
     	"testing"
     
     	"github.com/asdine/storm/v3"
    -	"github.com/spf13/afero"
    -
    +	"github.com/filebrowser/filebrowser/v2/files"
     	"github.com/filebrowser/filebrowser/v2/settings"
     	"github.com/filebrowser/filebrowser/v2/share"
     	"github.com/filebrowser/filebrowser/v2/storage"
     	"github.com/filebrowser/filebrowser/v2/storage/bolt"
     	"github.com/filebrowser/filebrowser/v2/users"
    +	"github.com/spf13/afero"
     )
     
     // symlinkShareStorage builds a storage whose single user is rooted at a real
    @@ -65,7 +65,7 @@ func symlinkShareStorage(t *testing.T) *storage.Storage {
     	}
     	st.Users = &customFSUser{
     		Store: st.Users,
    -		fs:    afero.NewBasePathFs(afero.NewOsFs(), scope),
    +		fs:    files.NewScopedFs(afero.NewOsFs(), scope),
     	}
     	return st
     }
    
  • http/public_test.go+6 4 modified
    @@ -8,13 +8,13 @@ import (
     	"testing"
     
     	"github.com/asdine/storm/v3"
    -	"github.com/spf13/afero"
    -
    +	"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) {
    @@ -220,7 +220,7 @@ func TestPublicShareHandlerRules(t *testing.T) {
     				t.Fatalf("failed to save settings: %v", err)
     			}
     
    -			fs := afero.NewBasePathFs(afero.NewOsFs(), t.TempDir())
    +			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)
     			}
    @@ -276,7 +276,9 @@ func (cu *customFSUser) Get(baseScope string, id interface{}) (*users.User, erro
     	if err != nil {
     		return nil, err
     	}
    -	user.Fs = cu.fs
    +	// Mirror production (users.User init), where a user's filesystem is always a
    +	// scoped, symlink-confining ScopedFs rather than a bare afero.Fs.
    +	user.Fs = files.NewScopedFs(cu.fs, "/")
     
     	return user, nil
     }
    
  • http/raw.go+1 6 modified
    @@ -10,11 +10,10 @@ import (
     	"path/filepath"
     	"strings"
     
    -	"github.com/mholt/archives"
    -
     	"github.com/filebrowser/filebrowser/v2/files"
     	"github.com/filebrowser/filebrowser/v2/fileutils"
     	"github.com/filebrowser/filebrowser/v2/users"
    +	"github.com/mholt/archives"
     )
     
     func slashClean(name string) string {
    @@ -115,10 +114,6 @@ func getFiles(d *data, path, commonPath string) ([]archives.FileInfo, error) {
     		return nil, nil
     	}
     
    -	if ok, err := files.WithinScope(d.user.Fs, path); err != nil || !ok {
    -		return nil, nil
    -	}
    -
     	info, err := d.user.Fs.Stat(path)
     	if err != nil {
     		return nil, err
    
  • http/resource.go+2 16 modified
    @@ -15,12 +15,11 @@ import (
     	"strings"
     	"time"
     
    -	"github.com/shirou/gopsutil/v4/disk"
    -	"github.com/spf13/afero"
    -
     	fberrors "github.com/filebrowser/filebrowser/v2/errors"
     	"github.com/filebrowser/filebrowser/v2/files"
     	"github.com/filebrowser/filebrowser/v2/fileutils"
    +	"github.com/shirou/gopsutil/v4/disk"
    +	"github.com/spf13/afero"
     )
     
     var resourceGetHandler = withUser(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
    @@ -228,15 +227,6 @@ func resourcePatchHandler(fileCache FileCache) handleFunc {
     			return http.StatusForbidden, nil
     		}
     
    -		for _, p := range []string{src, dst} {
    -			if ok, scopeErr := files.WithinScope(d.user.Fs, p); scopeErr != nil || !ok {
    -				if scopeErr != nil {
    -					return errToStatus(scopeErr), scopeErr
    -				}
    -				return http.StatusForbidden, nil
    -			}
    -		}
    -
     		err = checkParent(src, dst)
     		if err != nil {
     			return http.StatusBadRequest, err
    @@ -304,10 +294,6 @@ 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) {
    -	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 {
    
  • http/share.go+5 13 modified
    @@ -12,11 +12,9 @@ import (
     	"strings"
     	"time"
     
    -	"golang.org/x/crypto/bcrypt"
    -
     	fberrors "github.com/filebrowser/filebrowser/v2/errors"
    -	"github.com/filebrowser/filebrowser/v2/files"
     	"github.com/filebrowser/filebrowser/v2/share"
    +	"golang.org/x/crypto/bcrypt"
     )
     
     func withPermShare(fn handleFunc) handleFunc {
    @@ -103,20 +101,14 @@ var sharePostHandler = withPermShare(func(w http.ResponseWriter, r *http.Request
     	// Only allow sharing paths that currently exist. Otherwise a share could be
     	// created for a non-existent path and would silently start exposing
     	// whatever file later appears there.
    +	//
    +	// d.user.Fs is scoped, so Stat also refuses to follow a symlink whose target
    +	// escapes the user's scope: that returns a permission error here and so
    +	// blocks creating a share that points out of scope.
     	if _, err := d.user.Fs.Stat(r.URL.Path); err != nil {
     		return errToStatus(err), err
     	}
     
    -	// Refuse to create a share whose on-disk target escapes the user's scope
    -	// (e.g. via a symlink), mirroring the read/write guards. The public serve
    -	// path already rejects these, but blocking creation avoids dangling shares.
    -	if ok, err := files.WithinScope(d.user.Fs, r.URL.Path); err != nil || !ok {
    -		if err != nil {
    -			return errToStatus(err), err
    -		}
    -		return http.StatusForbidden, nil
    -	}
    -
     	var s *share.Link
     	var body share.CreateBody
     	if r.Body != nil {
    
  • http/tus_handlers.go+1 10 modified
    @@ -11,9 +11,8 @@ import (
     	"strings"
     	"time"
     
    -	"github.com/spf13/afero"
    -
     	"github.com/filebrowser/filebrowser/v2/files"
    +	"github.com/spf13/afero"
     )
     
     // keepUploadActive periodically touches the cache entry to prevent eviction during transfer
    @@ -45,10 +44,6 @@ func tusPostHandler(cache UploadCache) handleFunc {
     			return http.StatusForbidden, nil
     		}
     
    -		if ok, scopeErr := files.WithinScope(d.user.Fs, r.URL.Path); scopeErr != nil || !ok {
    -			return http.StatusForbidden, nil
    -		}
    -
     		file, err := files.NewFileInfo(&files.FileOptions{
     			Fs:         d.user.Fs,
     			Path:       r.URL.Path,
    @@ -167,10 +162,6 @@ func tusPatchHandler(cache UploadCache) handleFunc {
     			return http.StatusUnsupportedMediaType, nil
     		}
     
    -		if ok, scopeErr := files.WithinScope(d.user.Fs, r.URL.Path); scopeErr != nil || !ok {
    -			return http.StatusForbidden, nil
    -		}
    -
     		uploadOffset, err := getUploadOffset(r)
     		if err != nil {
     			return http.StatusBadRequest, fmt.Errorf("invalid upload offset")
    
  • http/tus_symlink_test.go+2 1 modified
    @@ -12,6 +12,7 @@ import (
     	"github.com/golang-jwt/jwt/v5"
     	"github.com/spf13/afero"
     
    +	"github.com/filebrowser/filebrowser/v2/files"
     	"github.com/filebrowser/filebrowser/v2/settings"
     	"github.com/filebrowser/filebrowser/v2/storage/bolt"
     	"github.com/filebrowser/filebrowser/v2/users"
    @@ -58,7 +59,7 @@ func TestTusHandlersRejectSymlinkScopeEscape(t *testing.T) {
     	}
     	st.Users = &customFSUser{
     		Store: st.Users,
    -		fs:    afero.NewBasePathFs(afero.NewOsFs(), userScope),
    +		fs:    files.NewScopedFs(afero.NewOsFs(), userScope),
     	}
     
     	// Forge a valid auth token for user ID 1.
    
  • users/users.go+20 21 modified
    @@ -3,11 +3,10 @@ package users
     import (
     	"path/filepath"
     
    -	"github.com/spf13/afero"
    -
     	fberrors "github.com/filebrowser/filebrowser/v2/errors"
     	"github.com/filebrowser/filebrowser/v2/files"
     	"github.com/filebrowser/filebrowser/v2/rules"
    +	"github.com/spf13/afero"
     )
     
     // ViewMode describes a view mode.
    @@ -20,23 +19,23 @@ const (
     
     // User describes a user.
     type User struct {
    -	ID                    uint          `storm:"id,increment" json:"id"`
    -	Username              string        `storm:"unique" json:"username"`
    -	Password              string        `json:"password"`
    -	Scope                 string        `json:"scope"`
    -	Locale                string        `json:"locale"`
    -	LockPassword          bool          `json:"lockPassword"`
    -	ViewMode              ViewMode      `json:"viewMode"`
    -	SingleClick           bool          `json:"singleClick"`
    -	RedirectAfterCopyMove bool          `json:"redirectAfterCopyMove"`
    -	Perm                  Permissions   `json:"perm"`
    -	Commands              []string      `json:"commands"`
    -	Sorting               files.Sorting `json:"sorting"`
    -	Fs                    afero.Fs      `json:"-" yaml:"-"`
    -	Rules                 []rules.Rule  `json:"rules"`
    -	HideDotfiles          bool          `json:"hideDotfiles"`
    -	DateFormat            bool          `json:"dateFormat"`
    -	AceEditorTheme        string        `json:"aceEditorTheme"`
    +	ID                    uint            `storm:"id,increment" json:"id"`
    +	Username              string          `storm:"unique" json:"username"`
    +	Password              string          `json:"password"`
    +	Scope                 string          `json:"scope"`
    +	Locale                string          `json:"locale"`
    +	LockPassword          bool            `json:"lockPassword"`
    +	ViewMode              ViewMode        `json:"viewMode"`
    +	SingleClick           bool            `json:"singleClick"`
    +	RedirectAfterCopyMove bool            `json:"redirectAfterCopyMove"`
    +	Perm                  Permissions     `json:"perm"`
    +	Commands              []string        `json:"commands"`
    +	Sorting               files.Sorting   `json:"sorting"`
    +	Fs                    *files.ScopedFs `json:"-" yaml:"-"`
    +	Rules                 []rules.Rule    `json:"rules"`
    +	HideDotfiles          bool            `json:"hideDotfiles"`
    +	DateFormat            bool            `json:"dateFormat"`
    +	AceEditorTheme        string          `json:"aceEditorTheme"`
     }
     
     // GetRules implements rules.Provider.
    @@ -93,13 +92,13 @@ func (u *User) Clean(baseScope string, fields ...string) error {
     	if u.Fs == nil {
     		scope := u.Scope
     		scope = filepath.Join(baseScope, filepath.Join("/", scope))
    -		u.Fs = afero.NewBasePathFs(afero.NewOsFs(), scope)
    +		u.Fs = files.NewScopedFs(afero.NewOsFs(), scope)
     	}
     
     	return nil
     }
     
     // FullPath gets the full path for a user's relative path.
     func (u *User) FullPath(path string) string {
    -	return afero.FullBaseFsPath(u.Fs.(*afero.BasePathFs), path)
    +	return afero.FullBaseFsPath(u.Fs.Base(), path)
     }
    

Vulnerability mechanics

Root cause

"The `afero.BasePathFs` used for per-user scope only provides lexical `../` confinement and does not prevent the HTTP file handlers from following symbolic links whose resolved target lies outside the user's scope."

Attack vector

An attacker must be a scoped File Browser user whose scope tree already contains a symlink (file symlink for Variant 1, directory symlink for Variant 2) that points outside the scope but to a path the server process can reach [ref_id=1]. For Variant 1, the attacker directly requests the symlink path via `/api/raw/{path}` or `/api/resources/{path}` and the handlers operate on the resolved target [ref_id=1]. For Variant 2, the attacker requests a regular file *through* a symlinked ancestor (e.g. `/escape_link/private.txt`); `LstatIfPossible` follows the ancestor symlink and returns the leaf as a regular file, causing the scope check that only triggers on symlinks to be skipped entirely [ref_id=1]. Read, write, TUS upload, and public-share creation endpoints are all affected [ref_id=1].

Affected code

`users/users.go` sets up the per-user scope via `afero.NewBasePathFs`, which only provides lexical confinement and does not block symlink following [ref_id=1]. The `stat()` and `readListing` functions in `files/file.go` only check the resolved target against the scope when the final path component is a symlink, missing the case where a symlinked ancestor is involved (Variant 2) [ref_id=1]. HTTP handlers in `http/raw.go`, `http/resource.go`, `http/tus_handlers.go`, and `http/share.go` perform file operations without verifying the resolved symlink target against the user's real scope [ref_id=1]. The fix centralises symlink confinement into a new `ScopedFs` wrapper (`files/scoped.go`) that refuses to follow any symlink whose on-disk target resolves outside the base, applied pervasively through the `User.Fs` field [patch_id=5751402].

What the fix does

The patch introduces a new `ScopedFs` type in `files/scoped.go` that wraps `*afero.BasePathFs` and adds a `guard()` call before every filesystem operation that would dereference a symlink (Open, Stat, OpenFile, MkdirAll, etc.) [patch_id=5751402]. The `guard()` method calls `within()`, which resolves the path with `filepath.EvalSymlinks` and verifies the resolved target is still under the scoped root; paths that do not yet exist walk up the directory tree until an existing ancestor is found [patch_id=5751402]. This centralised check replaces ad-hoc `WithinScope` calls scattered across multiple handlers — the old function is removed from `files/file.go` and the per-handler calls in `http/resource.go`, `http/tus_handlers.go`, `http/share.go`, and `fileutils/dir.go` are deleted because the underlying `Fs` now enforces the restriction at the filesystem layer itself [patch_id=5751402]. In `files/file.go`, `readListing` now uses `Stat` (which goes through the guarded `Fs`) and omits any symlink entry that returns `os.ErrPermission` instead of surfacing its target metadata [patch_id=5751402].

Preconditions

  • configA symlink (file or directory) must already exist lexically inside the user's scoped tree and point to a target outside that scope but reachable by the server process
  • authThe attacker must be an authenticated scoped user for Variant 1; for Variant 2's public-share path, an unauthenticated recipient suffices
  • inputThe symlink cannot be created through the web UI itself — it must be placed by an admin, a bind mount, SMB/NFS export, or other out-of-band mechanism

Reproduction

``` # Variant 1 — symlink as final path component root := t.TempDir() os.MkdirAll(filepath.Join(root, "u1"), 0755) os.MkdirAll(filepath.Join(root, "u2"), 0755) os.WriteFile(filepath.Join(root, "u2", "secret.txt"), []byte("other-secret"), 0644) os.Symlink(filepath.Join(root, "u2", "secret.txt"), filepath.Join(root, "u1", "link-out"))

// restricted is a File Browser user scoped to /u1 with Download permission. rr := authenticatedRequest(t, restricted, http.MethodGet, "/api/raw/link-out", nil)

# Variant 2 — file reached through a symlinked ancestor # escape_link -> /srv/users/otheruser GET /api/resources/escape_link/private.txt -> 200 OK GET /api/raw/escape_link/private.txt -> 200 OK POST /api/tus/escape_link/injected.txt ... -> 201 Created PATCH /api/tus/escape_link/injected.txt ... -> 204 No Content ``` [ref_id=1]

Generated on Jun 12, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

4

News mentions

0

No linked articles in our index yet.