Technologyglobalverified · 90%

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

When
Where
Global (internet)
Category
cyber_advisory · go

## 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: ```go 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

Sources

Defaxon links out to the original reporting and never republishes article text.

Correlated events

Computed by the Defaxon correlation engine — linked by shared actors, co-location, and temporal proximity. Scored hypotheses, never causal claims.

No correlated events found in the current window. As more events arrive, connections form automatically.

← Back to the live map