Summary
ScopedFs confines every File Browser user to a scope directory. Its within() guard is meant to reject any operation that follows a symbolic link out of that scope. When the link target does not exist yet, the guard walks up to the nearest existing ancestor and validates that instead. For a dangling symlink (target does not exist), the nearest existing ancestor is the in-scope directory containing the link, so the guard returns "in scope" and the subsequent os.OpenFile(O_CREATE) follows the link and creates the file at its out-of-scope target.
A post-auth user with Create and Modify permission can write attacker-controlled content to any non-existent path outside their scope that the File Browser process can write to. The precondition is a dangling symlink present inside the user's scope, which is the same out-of-band precondition the rest of ScopedFs is built to defend against.
This is a patch-gap variant of the GHSA-239w-m3h6-ch8v symlink confinement issue, not a resubmission of the already-published vulnerable-version behavior: GHSA-239w-m3h6-ch8v marks <= 2.63.13 vulnerable and 2.63.14 patched, while this proof reproduces on current master / v2.63.15 (be23ab3a15bf957928ecfed88de5ab67850c1b9c). The escaping-symlink-to-an-existing-target case is defended and tested. The dangling case is neither, and the gap is acknowledged in a code comment as "best-effort".
Root cause
files/scoped.go (commit be23ab3). The guard, including the maintainer comment that already flags this exact gap:
// 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) // LEXICAL parent of the link path
if parent == target {
break
}
target = parent
resolved, err = filepath.EvalSymlinks(target)
}
if err != nil {
return false, err
}
// ...
return resolved == root || strings.HasPrefix(resolved, prefix), nil
}When p is a symlink whose target does not exist, EvalSymlinks(target) returns fs.ErrNotExist. The loop takes the lexical parent of the link path (filepath.Dir), a real directory inside the scope, and EvalSymlinks of that resolves under the scope root. within() returns true and guard() permits the operation. The write then dereferences the link at the OS layer:
func (s *ScopedFs) OpenFile(name string, flag int, perm os.FileMode) (afero.File, error) {
if err := s.guard(name); err != nil { // returns nil for a dangling escaping symlink
return nil, err
}
return s.base.OpenFile(name, flag, perm) // os.OpenFile(O_CREATE) follows the link
}The assumption that breaks: within() treats "target does not exist" as "brand-new in-scope file" and validates the containing directory. But the path component being created is itself a symlink pointing outside the scope. O_CREATE follows it and creates the file at the link target, not inside the validated directory. The existing-target case is correctly blocked, because the walk-up resolves the link itself to an out-of-scope path. Only the dangling case slips through.
For the layout below:
/tmp/root/scope/escape -> /tmp/root/outside/created-by-http.txt
/tmp/root/outside/ # exists
/tmp/root/outside/created-by-http.txt # does not exist yetEvalSymlinks(/tmp/root/scope/escape) returns not-exist, and the fallback validates /tmp/root/scope. The final OpenFile still follows /tmp/root/scope/escape and creates /tmp/root/outside/created-by-http.txt.
Reachability over HTTP
Endpoint: POST /api/resources/<linkname>?override=true (also PUT, and POST/PATCH /api/tus/...). Verified trace against the audited source:
http/resource.goresourcePostHandlerrequiresd.user.Perm.Create(else 403).files.NewFileInfois called. For a dangling symlink,stat()infiles/file.godoesLstatIfPossible(sees the symlink,err == nil,IsSymlink = true), thenFs.Statfollows the link and fails with ENOENT, so the code returns the symlinkFileInfowitherr == nil. The handler therefore enters the "file exists" branch.- The branch requires
override == "true"andd.user.Perm.Modify, then proceeds. writeFile(d.user.Fs, r.URL.Path, r.Body, ...)callsafs.OpenFile(dst, os.O_RDWR|os.O_CREATE|os.O_TRUNC, fileMode).ScopedFs.OpenFilerunsguard(), which passes for the dangling link, thenos.OpenFilefollows the link and creates the file outside the scope with the request body as content.
The TUS path (http/tus_handlers.go tusPostHandler -> OpenFile, then tusPatchHandler) reaches the same sink.
Why existing defenses do not apply
afero.BasePathFslexical confinement only neutralizes... A plain link name passes it unchanged.ScopedFs.within()is the dedicated symlink defense, and it is the component that fails: the not-exist walk-up validates the link's parent directory instead of the link.- The project's symlink tests (
http/tus_symlink_test.goTestTusHandlersRejectSymlinkScopeEscape,files/file_test.go) only exercise escaping symlinks whose target exists. Those are blocked. The dangling variant is never tested, so the regression suite does not catch it.
Reference : https://github.com/filebrowser/filebrowser/security/advisories/GHSA-8wc8-hf36-mjh9
