Root cause
Path & link handling
fix: address three security disclosures (archive traversal, login DoS, symlink escape)
How AI contributed: Flawed AI-written codePublished Jul 20, 2026 in
filebrowser/filebrowser
The AI-written hardening added symlink scope checks for the write path in http/resource.go and the read path in files/file.go, refusing targets that resolve outside the user's scope, but neither check handled a symlink whose target does not exist yet. A scoped user could therefore let a write follow a dangling link and land outside their own scope. The follow-up fix bounds symlink resolution with maxSymlinkHops in files/scoped.go and adds a regression test asserting that a dangling symlink is never followed for writes.
The scope checks compared a symlink's resolved target, which leaves a dangling link with nothing to compare, so the link was treated as safe and the subsequent write could leave the user's scope. Bounding resolution with maxSymlinkHops and covering the dangling case in tests closes the gap.
Root cause
fix: address three security disclosures (archive traversal, login DoS, symlink escape)
Fix
fix: dangling symlink, write, delete scope bugs
Fix by Henrique Dias · no AI marker found
Case facts
@@ -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 {@@ -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 {@@ -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 {@@ -5,6 +5,7 @@ import ( "net/http/httptest" "os" "path/filepath"+ "strings" "testing" "time" @@ -14,6 +15,7 @@ import ( "github.com/filebrowser/filebrowser/v2/diskcache" "github.com/filebrowser/filebrowser/v2/settings"+ "github.com/filebrowser/filebrowser/v2/storage" "github.com/filebrowser/filebrowser/v2/storage/bolt" "github.com/filebrowser/filebrowser/v2/users" )@@ -115,3 +117,108 @@ func signToken(t *testing.T, perm users.Permissions, key []byte) string { } return signed }++// scopedUserStorage returns a storage whose single user (ID 1) is scoped to+// userScope through a symlink-confining ScopedFs (via customFSUser), mirroring+// production. Used by the symlink scope-escape regression tests below.+func scopedUserStorage(t *testing.T, userScope string, perm users.Permissions, key []byte) *storage.Storage {+ t.Helper()+ db, err := storm.Open(filepath.Join(t.TempDir(), "db"))+ if err != nil {+ t.Fatalf("failed to open db: %v", err)+ }+ t.Cleanup(func() { _ = db.Close() })++ st, err := bolt.NewStorage(db)+ if err != nil {+ t.Fatalf("failed to get storage: %v", err)+ }+ if err := st.Users.Save(&users.User{Username: "u", Password: "pw", Perm: perm}); err != nil {+ t.Fatalf("failed to save user: %v", err)+ }+ if err := st.Settings.Save(&settings.Settings{Key: key}); err != nil {+ t.Fatalf("failed to save settings: %v", err)+ }+ st.Users = &customFSUser{+ Store: st.Users,+ fs: afero.NewBasePathFs(afero.NewOsFs(), userScope),+ }+ return st+}++// Regression for the dangling-symlink write escape (GHSA-8wc8-hf36-mjh9 /+// GHSA-fh54-6rfh-r8f3): POSTing to an in-scope dangling symlink whose target is+// outside the scope must not dereference the link to create the out-of-scope+// file.+func TestResourcePostRejectsDanglingSymlinkWriteEscape(t *testing.T) {+ root := t.TempDir()+ userScope := filepath.Join(root, "user")+ outside := filepath.Join(root, "outside")+ for _, d := range []string{userScope, outside} {+ if err := os.MkdirAll(d, 0o755); err != nil {+ t.Fatal(err)+ }+ }+ // A dangling symlink inside the scope pointing at a not-yet-existing file+ // outside it (planted out-of-band, per the advisory preconditions).+ outsideTarget := filepath.Join(outside, "created.txt")+ if err := os.Symlink(outsideTarget, filepath.Join(userScope, "evil")); err != nil {+ t.Skipf("cannot create symlink: %v", err)+ }++ key := []byte("test-signing-key")+ perm := users.Permissions{Create: true, Modify: true}+ st := scopedUserStorage(t, userScope, perm, key)+ signed := signToken(t, perm, key)++ req, _ := http.NewRequest(http.MethodPost, "/evil?override=true", strings.NewReader("http-outside"))+ req.Header.Set("X-Auth", signed)+ rec := httptest.NewRecorder()+ handle(resourcePostHandler(diskcache.NewNoOp()), "", st, &settings.Server{}).ServeHTTP(rec, req)++ if _, statErr := os.Stat(outsideTarget); statErr == nil {+ data, _ := os.ReadFile(outsideTarget)+ t.Fatalf("VULNERABLE: out-of-scope file created via dangling symlink (status=%d, content=%q)", rec.Code, string(data))+ }+ if rec.Code != http.StatusForbidden {+ t.Errorf("expected 403, got %d body=%q", rec.Code, rec.Body.String())+ }+}++// Regression for the symlink-following delete escape (GHSA-hq4g-mpch-f9vp /+// GHSA-fmm7-x4gx-8jhr): a Create-only user POSTing to a child of an escaping@@ -25,6 +25,11 @@ var ( _ afero.Lstater = (*ScopedFs)(nil) ) +// maxSymlinkHops bounds how many dangling symlinks within() will follow before+// giving up, so a pathological chain cannot loop forever. It mirrors the kernel+// MAXSYMLINKS limit; the operation is rejected once the bound is exceeded.+const maxSymlinkHops = 255+ func NewScopedFs(source afero.Fs, path string) *ScopedFs { if s, ok := source.(*ScopedFs); ok { source = s.base@@ -61,13 +66,10 @@ func (s *ScopedFs) guard(name string) error { // // 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.+// are always allowed. A dangling symlink — a link whose target does not exist+// yet — is the exception: it is followed to where it points and validated+// there, so a write cannot dereference the link to create a file outside the+// scope. func (s *ScopedFs) within(p string) (bool, error) { root, err := filepath.EvalSymlinks(afero.FullBaseFsPath(s.base, "/")) if err != nil {@@ -76,12 +78,43 @@ func (s *ScopedFs) within(p string) (bool, error) { 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+ // When target does not resolve, work out where the operation would actually+ // land. A non-existent regular path resolves to the file that would be+ // created inside its containing directory, so walk up to the nearest+ // existing ancestor and validate that. But when target itself is a dangling+ // symlink, follow it one level instead: validating its lexical parent would+ // wrongly accept a link pointing outside the scope, letting a write follow+ // the link and create the file out of bounds.+ for hops := 0; errors.Is(err, fs.ErrNotExist); {+ if fi, lerr := os.Lstat(target); lerr == nil && fi.Mode()&os.ModeSymlink != 0 {+ hops+++ if hops > maxSymlinkHops {+ return false, os.ErrPermission+ }+ dest, rerr := os.Readlink(target)+ if rerr != nil {+ return false, rerr+ }+ if !filepath.IsAbs(dest) {+ // Resolve the link relative to the directory that really contains+ // it, not its lexical parent: a symlinked ancestor could otherwise+ // shift the computed target back into scope while the real write+ // lands outside it. The parent is guaranteed to resolve here+ // because os.Lstat above already traversed it.+ base, berr := filepath.EvalSymlinks(filepath.Dir(target))+ if berr != nil {+ return false, berr+ }+ dest = filepath.Join(base, dest)+ }+ target = filepath.Clean(dest)+ } else {+ parent := filepath.Dir(target)+ if parent == target {+ break+ }+ target = parent }- target = parent resolved, err = filepath.EvalSymlinks(target) } if err != nil {@@ -136,10 +169,16 @@ func (s *ScopedFs) OpenFile(name string, flag int, perm os.FileMode) (afero.File } func (s *ScopedFs) Remove(name string) error {+ if err := s.guard(name); err != nil {+ return err+ } return s.base.Remove(name) } func (s *ScopedFs) RemoveAll(path string) error {@@ -92,6 +92,125 @@ func TestScopedFs(t *testing.T) { t.Fatalf("expected in-scope symlink target to be accessible, got %v", err) } })++ // Regression for the dangling-symlink write escape (GHSA-8wc8-hf36-mjh9 /+ // GHSA-fh54-6rfh-r8f3): a symlink whose target does not exist yet must not be+ // followed for writes. Previously within() validated the link's in-scope+ // parent directory, so OpenFile(O_CREATE) dereferenced the link and created+ // the file at its out-of-scope target.+ t.Run("write through a dangling escaping symlink is rejected", func(t *testing.T) {+ base := t.TempDir()+ scope := filepath.Join(base, "scope")+ outside := filepath.Join(base, "outside")+ for _, d := range []string{scope, outside} {+ if err := os.MkdirAll(d, 0o755); err != nil {+ t.Fatal(err)+ }+ }+ outsideTarget := filepath.Join(outside, "created.txt") // does not exist yet+ if err := os.Symlink(outsideTarget, filepath.Join(scope, "evil")); err != nil {+ t.Skipf("cannot create symlink: %v", err)+ }+ fs := NewScopedFs(afero.NewOsFs(), scope)++ f, err := fs.OpenFile("/evil", os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o644)+ if err == nil {+ _ = f.Close()+ t.Fatal("VULNERABLE: write through a dangling escaping symlink was allowed")+ }+ if !os.IsPermission(err) {+ t.Fatalf("expected permission error, got %v", err)+ }+ if _, statErr := os.Stat(outsideTarget); statErr == nil {+ t.Fatal("VULNERABLE: file was created outside the scope")+ }+ })++ // A dangling *relative* symlink that lives under an escaping directory+ // symlink must be resolved against the link's real directory, not its lexical+ // parent. Otherwise the symlinked ancestor can shift the computed target back+ // into scope while the real OS write lands outside it.+ t.Run("write through a dangling relative symlink under a symlinked dir is rejected", func(t *testing.T) {+ base := t.TempDir()+ scope := filepath.Join(base, "scope")+ outside := filepath.Join(base, "outside")+ for _, d := range []string{scope, outside} {+ if err := os.MkdirAll(d, 0o755); err != nil {+ t.Fatal(err)+ }+ }+ // An escaping directory symlink inside the scope: /scope/m -> /base/outside.+ if err := os.Symlink(outside, filepath.Join(scope, "m")); err != nil {+ t.Skipf("cannot create symlink: %v", err)+ }+ // A relative dangling symlink inside the escaping dir whose target,+ // resolved against the real directory (/base/outside), is /base/escaped —+ // outside the scope.+ if err := os.Symlink("../escaped", filepath.Join(outside, "evil")); err != nil {+ t.Skipf("cannot create symlink: %v", err)+ }+ fs := NewScopedFs(afero.NewOsFs(), scope)++ f, err := fs.OpenFile("/m/evil", os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o644)+ if err == nil {+ _ = f.Close()+ t.Fatal("VULNERABLE: write through a dangling relative symlink under a symlinked dir was allowed")+ }+ if !os.IsPermission(err) {+ t.Fatalf("expected permission error, got %v", err)+ }+ if _, statErr := os.Stat(filepath.Join(base, "escaped")); statErr == nil {+ t.Fatal("VULNERABLE: file was created outside the scope")+ }+ })++ // Regression for the symlink-following delete escape (GHSA-hq4g-mpch-f9vp /+ // GHSA-fmm7-x4gx-8jhr): Remove/RemoveAll used to skip guard(), so RemoveAll+ // followed a symlinked ancestor escaping the scope and deleted an+ // out-of-scope file.+ t.Run("RemoveAll through an escaping symlink is rejected", func(t *testing.T) {+ base := t.TempDir()+ scope := filepath.Join(base, "scope")+ outside := filepath.Join(base, "outside")+ for _, d := range []string{scope, outside} {+ if err := os.MkdirAll(d, 0o755); err != nil {+ t.Fatal(err)+ }+ }+ victim := filepath.Join(outside, "victim.txt")AI-assisted change 9724277e1be7e1cf6af8a2904cdb21e89fa0a1627b6d9bd11bc6fefdedfec88c · Fix cf5dbfecb4679d89ea5c0e3d7e0e230902cff6afe71bfdc12f69b19e53b56ee8