Summary
File Browser enforces per-user scope with afero.NewBasePathFs(afero.NewOsFs(), scope), set up in users/users.go. This blocks lexical ../ traversal, but it does not stop the HTTP file handlers from following symbolic links before they open, serve, write, share, or list a file. As a result, a scoped user — and in some cases an unauthenticated public-share recipient — can cross the intended scope boundary by following a symlink whose path is lexically inside their scope but whose target is outside it.
Two distinct shapes are covered here:
- Variant 1 — symlink as the final path component. A symlink that lives inside the user's scoped tree and points to a file under the server root but outside the scope. The handlers record the symlink (
IsSymlink) but then resolve and operate on the target anyway.
- Variant 2 — file or directory reached through a symlinked ancestor. A regular file requested through a symlinked directory.
Read, write (including TUS resumable uploads), share creation, and public-share serving are all affected.
Impact
In a multi-user deployment, if a symlink (a file symlink for Variant 1, or a directory symlink for Variant 2) exists inside a restricted user's scoped tree and resolves to a location outside that scope but reachable by the server process, the boundary can be crossed. Concretely, a user holding only normal File Browser permissions can:
- Read out-of-scope file contents and metadata via
GET /api/raw/{path} and GET /api/resources/{path}.
- Overwrite an out-of-scope target via
POST /api/resources/{path}?override=true.
- Overwrite or create an out-of-scope target via the TUS resumable upload path:
POST /api/tus/{path}?override=true followed by PATCH /api/tus/{path}.
- Create a public share for an out-of-scope target via
POST /api/share/{path}, exposing it through GET /api/public/dl/{hash}.
For Variant 2, the same exposure reaches public-share recipients: a normal public directory share whose subtree contains a linked descendant lets an unauthenticated recipient read regular files behind the link, pull them into the share's archive download, and see the resolved target in directory listings.
This breaks the confidentiality and integrity guarantees that per-user scopes and password/anonymous shares are relied upon to provide, for any data the server process can reach.
Technical details
Users are rooted with afero.NewBasePathFs(afero.NewOsFs(), scope). Base-path rooting blocks lexical ../ traversal but does not prevent ordinary filesystem operations from following a symlink whose path is lexically inside the base.
The metadata layer records symlinks but does not consistently re-check the resolved target against the user's real scope:
- In
files/file.go, stat() calls LstatIfPossible, sets IsSymlink, and only invokes the WithinScope containment check when file.IsSymlink == true. For Variant 1, this guard (where present) covers the final-element symlink; on the commit tested for Variant 1 the handler still resolved the target with opts.Fs.Stat(opts.Path) and served it. For Variant 2, LstatIfPossible follows a symlinked ancestor and returns the leaf as a regular file (IsSymlink == false), so stat() returns early and the scope check never runs at all.
readListing in files/file.go follows symlink entries to display the target's metadata.
http/raw.go builds a file object for the requested path and serves non-directories; its archive walker getFiles follows symlinks via Stat/Open, pulling linked descendants into archive downloads.
http/resource.go writes request bodies with writeFile(d.user.Fs, r.URL.Path, ...), and the destination open follows symlinks.
http/tus_handlers.go (tusPostHandler, tusPatchHandler) calls MkdirAll/OpenFile on the request path directly with no containment check. Because a brand-new leaf does not stat an existing file, it skips the scope check entirely.
http/share.go stores a share for r.URL.Path without checking that the path is not a symlink escape; http/public.go later serves it for unauthenticated downloads (routed at http/http.go:90-91).
http/data.go applies dotfile and rule checks to the request path string, but never compares the resolved symlink target against the user's real scope.
Proof of concept
Variant 1 — symlink as final path component
Harness layout: server root is a temp directory; restricted user restricted is scoped to /u1 with create, modify, rename, share, and download permissions; a second scope /u2 holds the outside target /u2/secret.txt containing other-secret; and /u1/link-out is a symlink to /u2/secret.txt.
Confirmed bypasses (route-level tests against the real HTTP handlers):
GET /api/raw/link-out → 200 OK, body contains other-secret from /u2/secret.txt.
POST /api/resources/link-out?override=true → 200 OK, /u2/secret.txt changed to pwn.
POST /api/tus/link-out?override=true → 201, then PATCH /api/tus/link-out → 204, /u2/secret.txt changed.
POST /api/share/link-out → 200 OK, created a public share whose GET /api/public/dl/{hash} returned a body containing other-secret.
Minimal core of the read proof:
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)
if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "other-secret") {
t.Fatalf("raw symlink exposed outside target: status=%d body=%q", rr.Code, rr.Body.String())
}
Variant 2 — file reached through a symlinked ancestor
Authenticated scoped user whose scope contains a directory symlink escape_link -> /srv/users/otheruser:
# The symlink itself is correctly blocked
GET /api/resources/escape_link -> 403 Forbidden
# A regular file THROUGH the symlinked directory is not
GET /api/resources/escape_link/private.txt -> 200 OK {"content":"OTHER_USER_SECRET_DATA=...",...}
GET /api/raw/escape_link/private.txt -> 200 OK OTHER_USER_SECRET_DATA=...
# Create/overwrite THROUGH the symlinked directory (TUS)
POST /api/tus/escape_link/injected.txt (Upload-Length: 20) -> 201 Created
PATCH /api/tus/escape_link/injected.txt (Upload-Offset: 0) -> 204 No Content (written into /srv/users/otheruser/)
Public directory share for /shared, where /shared/link -> ../private and private/secret.txt lives outside the share:
GET /api/public/dl/<hash>/link/secret.txt -> 200 OK symlink-secret
GET /api/public/share/<hash>/link/secret.txt -> 200 OK {"path":"/link/secret.txt", ...}
Requesting the whole share as an archive pulls link/secret.txt into the zip, and listing the share root exposes the link entry with its resolved target metadata.
Controls that held
The same harness confirmed that ordinary traversal is still rejected, so this is not generic ../ traversal:
GET /api/resources/../u2/secret.txt?checksum=sha256 did not succeed as the restricted user.
GET /api/resources/%2e%2e/u2/secret.txt did not succeed (encoded dot-dot).
POST /api/resources/../u2/new.txt did not create /u2/new.txt.
PATCH /api/resources/own.txt?action=rename&destination=/../u2/moved.txt did not move a file outside scope.
Affected code
users/users.go (scope setup); files/file.go (stat, readListing); http/raw.go (getFiles); http/resource.go (writeFile destination); http/tus_handlers.go (tusPostHandler, tusPatchHandler); http/share.go; http/public.go; http/http.go:90-91 (public routes); http/data.go (string-only path checks).
Remediation
Resolve symlinks and verify that the resolved target remains inside the user's real scoped root before any file operation — serving, sharing, writing, truncating, renaming, copying, or deleting. Specifically:
- Call
WithinScope (which resolves every path component with filepath.EvalSymlinks) for all paths in stat(), not only when the final element is a symlink. This closes the ancestor-symlink gap (Variant 2).
- Add a
WithinScope check before MkdirAll/OpenFile in tusPostHandler and tusPatchHandler, so a not-yet-existing leaf cannot skip containment.
- Omit entries whose resolved target escapes the scope from
readListing, and skip them in getFiles before stat/open/recursion.
- Apply the same resolved-path check consistently to public share creation and public share serving.
- As an alternative or defense-in-depth, reject symlinks for file operations unless an explicit administrator option enables them.
Add regression tests covering symlink reads, overwrites, TUS create/write, public shares (download, share-info, listing, and archive read paths), and the existing dot-dot controls — plus a positive test confirming that legitimately in-scope symlinks still resolve.
Limitations and non-claims
- This is not generic
../ path traversal; dot-dot and encoded dot-dot controls held in the route-level tests.
- This is not a proxy-auth confusion issue; the proofs use normal authenticated requests for a restricted user (and, for Variant 2's share case, an ordinary public-share recipient).
- The proofs assume the relevant symlink already exists inside the scoped tree, or that another allowed workflow in the deployment can place it there — for example an SMB/NFS export, a Docker bind-mount, or an admin-created link. Web-UI-only creation of the symlink from scratch was not demonstrated.
References
Summary
File Browser enforces per-user scope with
afero.NewBasePathFs(afero.NewOsFs(), scope), set up inusers/users.go. This blocks lexical../traversal, but it does not stop the HTTP file handlers from following symbolic links before they open, serve, write, share, or list a file. As a result, a scoped user — and in some cases an unauthenticated public-share recipient — can cross the intended scope boundary by following a symlink whose path is lexically inside their scope but whose target is outside it.Two distinct shapes are covered here:
IsSymlink) but then resolve and operate on the target anyway.Read, write (including TUS resumable uploads), share creation, and public-share serving are all affected.
Impact
In a multi-user deployment, if a symlink (a file symlink for Variant 1, or a directory symlink for Variant 2) exists inside a restricted user's scoped tree and resolves to a location outside that scope but reachable by the server process, the boundary can be crossed. Concretely, a user holding only normal File Browser permissions can:
GET /api/raw/{path}andGET /api/resources/{path}.POST /api/resources/{path}?override=true.POST /api/tus/{path}?override=truefollowed byPATCH /api/tus/{path}.POST /api/share/{path}, exposing it throughGET /api/public/dl/{hash}.For Variant 2, the same exposure reaches public-share recipients: a normal public directory share whose subtree contains a linked descendant lets an unauthenticated recipient read regular files behind the link, pull them into the share's archive download, and see the resolved target in directory listings.
This breaks the confidentiality and integrity guarantees that per-user scopes and password/anonymous shares are relied upon to provide, for any data the server process can reach.
Technical details
Users are rooted with
afero.NewBasePathFs(afero.NewOsFs(), scope). Base-path rooting blocks lexical../traversal but does not prevent ordinary filesystem operations from following a symlink whose path is lexically inside the base.The metadata layer records symlinks but does not consistently re-check the resolved target against the user's real scope:
files/file.go,stat()callsLstatIfPossible, setsIsSymlink, and only invokes theWithinScopecontainment check whenfile.IsSymlink == true. For Variant 1, this guard (where present) covers the final-element symlink; on the commit tested for Variant 1 the handler still resolved the target withopts.Fs.Stat(opts.Path)and served it. For Variant 2,LstatIfPossiblefollows a symlinked ancestor and returns the leaf as a regular file (IsSymlink == false), sostat()returns early and the scope check never runs at all.readListinginfiles/file.gofollows symlink entries to display the target's metadata.http/raw.gobuilds a file object for the requested path and serves non-directories; its archive walkergetFilesfollows symlinks viaStat/Open, pulling linked descendants into archive downloads.http/resource.gowrites request bodies withwriteFile(d.user.Fs, r.URL.Path, ...), and the destination open follows symlinks.http/tus_handlers.go(tusPostHandler,tusPatchHandler) callsMkdirAll/OpenFileon the request path directly with no containment check. Because a brand-new leaf does not stat an existing file, it skips the scope check entirely.http/share.gostores a share forr.URL.Pathwithout checking that the path is not a symlink escape;http/public.golater serves it for unauthenticated downloads (routed athttp/http.go:90-91).http/data.goapplies dotfile and rule checks to the request path string, but never compares the resolved symlink target against the user's real scope.Proof of concept
Variant 1 — symlink as final path component
Harness layout: server root is a temp directory; restricted user
restrictedis scoped to/u1with create, modify, rename, share, and download permissions; a second scope/u2holds the outside target/u2/secret.txtcontainingother-secret; and/u1/link-outis a symlink to/u2/secret.txt.Confirmed bypasses (route-level tests against the real HTTP handlers):
GET /api/raw/link-out→200 OK, body containsother-secretfrom/u2/secret.txt.POST /api/resources/link-out?override=true→200 OK,/u2/secret.txtchanged topwn.POST /api/tus/link-out?override=true→201, thenPATCH /api/tus/link-out→204,/u2/secret.txtchanged.POST /api/share/link-out→200 OK, created a public share whoseGET /api/public/dl/{hash}returned a body containingother-secret.Minimal core of the read proof:
Variant 2 — file reached through a symlinked ancestor
Authenticated scoped user whose scope contains a directory symlink
escape_link -> /srv/users/otheruser:Public directory share for
/shared, where/shared/link -> ../privateandprivate/secret.txtlives outside the share:Requesting the whole share as an archive pulls
link/secret.txtinto the zip, and listing the share root exposes thelinkentry with its resolved target metadata.Controls that held
The same harness confirmed that ordinary traversal is still rejected, so this is not generic
../traversal:GET /api/resources/../u2/secret.txt?checksum=sha256did not succeed as the restricted user.GET /api/resources/%2e%2e/u2/secret.txtdid not succeed (encoded dot-dot).POST /api/resources/../u2/new.txtdid not create/u2/new.txt.PATCH /api/resources/own.txt?action=rename&destination=/../u2/moved.txtdid not move a file outside scope.Affected code
users/users.go(scope setup);files/file.go(stat,readListing);http/raw.go(getFiles);http/resource.go(writeFiledestination);http/tus_handlers.go(tusPostHandler,tusPatchHandler);http/share.go;http/public.go;http/http.go:90-91(public routes);http/data.go(string-only path checks).Remediation
Resolve symlinks and verify that the resolved target remains inside the user's real scoped root before any file operation — serving, sharing, writing, truncating, renaming, copying, or deleting. Specifically:
WithinScope(which resolves every path component withfilepath.EvalSymlinks) for all paths instat(), not only when the final element is a symlink. This closes the ancestor-symlink gap (Variant 2).WithinScopecheck beforeMkdirAll/OpenFileintusPostHandlerandtusPatchHandler, so a not-yet-existing leaf cannot skip containment.readListing, and skip them ingetFilesbefore stat/open/recursion.Add regression tests covering symlink reads, overwrites, TUS create/write, public shares (download, share-info, listing, and archive read paths), and the existing dot-dot controls — plus a positive test confirming that legitimately in-scope symlinks still resolve.
Limitations and non-claims
../path traversal; dot-dot and encoded dot-dot controls held in the route-level tests.References