DOKIMASecurity review report

Overview

afero BasePathFs confines by textual prefix only and follows symlinks, so an in-scope symlink escapes the user root on read and write

high Possibly Valid medium confidence

DOK-100020-FILEBROWSER-FILESYSTEM-PATH-RE-SYMLINK-FOLLOWING · filebrowser · filesystem-path-resolution

Status

Possibly Valid

The confinement weakness is fully code-proven for both read and write (afero BasePathFs performs no symlink resolution; all sinks lack O_NOFOLLOW), and the app-creates-no-symlinks premise is verified in source. Rated possibly_valid rather than fully_valid solely because the triggering symlink must be introduced out-of-band — the attacker cannot create it through FileBrowser — and that environmental precondition is not determinable from source. Severity held at high on the worst-case shared-volume/multi-tenant deployment; a strict single-tenant deployment nullifies it.

Repository / Component

Repository
filebrowser
Component
filesystem-path-resolution

Plain-English Description

Each user is meant to stay inside their own folder, but the confinement check only compares text, it does not resolve symbolic links. If a shortcut pointing outside the folder exists inside a user's area, the user can read or overwrite files anywhere the server can reach — including the app's database.

Description of the Underlying Issue

User filesystem access is confined by afero BasePathFs, whose RealPath (github.com/spf13/afero@v1.15.0 basepath.go:53-65) only does filepath.Clean plus a strings.HasPrefix check and performs NO EvalSymlinks; Open/OpenFile/Stat (basepath.go:108-159) then delegate the prefixed path straight to OsFs, so the OS follows any in-base symlink to its real target. Every read sink follows the link: files/file.go:109-166 stat() detects a symlink via LstatIfPossible then follows it with Fs.Stat; Checksum (files/file.go:170-202) opens and io.Copy-reads the target; http/raw.go:212-224 streams it via ServeContent. Every write sink likewise opens without O_NOFOLLOW or an Lstat guard: writeFile (http/resource.go:297-315) OpenFile(O_RDWR|O_CREATE|O_TRUNC), CopyFile (fileutils/file.go:50), and tus (http/tus_handlers.go:67-87). The rules Checker (http/data.go:36-45) evaluates only the virtual path string and never resolves the real target. The confinement root is BasePathFs(OsFs, Root+Scope) (users/users.go:93-96) while the bbolt DB defaults to ./filebrowser.db (cmd/root.go:83) outside every user root. Textual '..' escapes remain blocked by RealPath. A workspace-wide grep confirms FileBrowser creates no symlinks (no os.Symlink/os.Link/SymlinkIfPossible) and performs no archive extraction on upload, so the app itself cannot create the triggering link — it must be introduced out-of-band.

Potential Attack

READ: a symlink /scope/leak -> /abs/filebrowser.db already exists inside the user's scope (placed via a shared/bind mount, co-tenant container, NFS, or operator). The user requests GET /api/raw/leak; rawFileHandler calls file.Fs.Open('/leak'); BasePathFs.RealPath returns '/scope/leak' (prefix check passes, no EvalSymlinks); OsFs.Open follows to the DB; ServeContent streams settings.Key and bcrypt hashes. WRITE: with /scope/link -> /abs/etc/cron.d/x, PUT /api/resources/link → writeFile OpenFile(O_CREATE|O_TRUNC) follows the link and truncates+writes the host target.

Outcomes of Potential Attack

Out-of-scope disclosure of any file the server process can read (including the bbolt DB holding settings.Key and password hashes — enabling forged HS256 admin JWTs) and out-of-scope overwrite/creation of any file the process can write (e.g. cron jobs, config), i.e. full-host read/write and authentication compromise on a shared-filesystem deployment.

Affected Scope

All per-user filesystem read (raw/checksum/stat/listing) and write (upload/save/copy/rename/tus) operations, which resolve paths through afero BasePathFs without symlink confinement. Impact chain reaches the bbolt DB (default ./filebrowser.db) and thus settings.Key.

Suggested Fix (plain english)

Resolve the real target of every path and refuse anything that leaves the user's folder, and refuse to open symbolic links.

Suggested Fix (detailed)

Resolve real targets with filepath.EvalSymlinks and reject any that escape the scope root, OR Lstat-and-refuse to open/write symlinked entries, OR wrap the user FS in a symlink-restricted afero layer; open writes with O_NOFOLLOW where the platform supports it. Independently, store the bbolt DB and the JWT signing key outside any user-reachable root regardless of the symlink fix. Verify by placing an in-scope symlink to an out-of-scope file and confirming both GET /api/raw and PUT are refused. Resolve real targets with filepath.EvalSymlinks and reject any escaping the scope root, or Lstat-and-refuse to open/write symlinked entries, or wrap the user FS in a symlink-restricted afero layer; open writes with O_NOFOLLOW where supported. Store the DB and signing key outside any user-reachable root regardless.

Validation

The confinement weakness is fully code-proven for both read and write (afero BasePathFs performs no symlink resolution; all sinks lack O_NOFOLLOW), and the app-creates-no-symlinks premise is verified in source. Rated possibly_valid rather than fully_valid solely because the triggering symlink must be introduced out-of-band — the attacker cannot create it through FileBrowser — and that environmental precondition is not determinable from source. Severity held at high on the worst-case shared-volume/multi-tenant deployment; a strict single-tenant deployment nullifies it.

first_opinion

Full Evidence

github.com/spf13/afero@v1.15.0 basepath.go:53-65 RealPath does filepath.Clean + a strings.HasPrefix(path,bpath) check and NO EvalSymlinks; Open/OpenFile/Stat (basepath.go:108-159) delegate the prefixed path straight to OsFs, so the OS follows an in-base symlink to any target
files/file.go:109-166 stat() detects a symlink via LstatIfPossible then follows it with opts.Fs.Stat; files/file.go:170-202 Checksum opens+io.Copy-reads the followed target
http/raw.go:212-224 rawFileHandler file.Fs.Open(file.Path) -> http.ServeContent streams the target for any file type/size
http/resource.go:297-315 writeFile afs.OpenFile(dst,O_RDWR|O_CREATE|O_TRUNC); fileutils/file.go:50 CopyFile afs.OpenFile(dest,O_RDWR|O_CREATE|O_TRUNC); http/tus_handlers.go:67-87 OpenFile(O_CREATE|O_WRONLY[|O_TRUNC]) — none pass O_NOFOLLOW or Lstat-guard the target
http/data.go:36-45 Check evaluates only the virtual path string against rules; it never resolves or rejects the real symlink target
users/users.go:93-96 confinement root = BasePathFs(OsFs, Root+Scope); cmd/root.go:83 DB default ./filebrowser.db lies outside every user root
grep across the workspace: no os.Symlink/os.Link/SymlinkIfPossible anywhere in app code, and no unarchive/untar/unzip on upload (only archiver.Archive for download at raw.go:205) — the app cannot create the required symlink
Proven fact: afero BasePathFs enforces confinement by textual prefix only and performs no symlink resolution, so a symlink located inside a user's scope but pointing outside it is followed by the OS on both read and write; textual '..' escapes remain blocked by RealPath.
Proven fact: Every read sink (raw stream, checksum, stat, listing) and every write sink (PUT/POST-override, copy, rename, tus) opens the target without O_NOFOLLOW/EvalSymlinks, so a symlinked entry yields out-of-scope read or O_TRUNC overwrite.
Proven fact: The bbolt DB (./filebrowser.db) holding settings.Key and bcrypt hashes sits outside all user roots; a followed read symlink discloses it, enabling forged admin JWTs (HS256 signed with settings.Key).
Proven fact: FileBrowser creates no symlinks and performs no archive extraction, so the vulnerable primitive is a genuine confinement weakness in the resolution layer, independent of any app-side link creation.
Unvalidated fact: Whether a symlink pointing outside a user's scope actually exists inside that scope in a given deployment. FileBrowser cannot create one (no os.Symlink and no symlink-preserving archive extraction on any code path), so the link must be introduced out-of-band — shared/bind mount, a co-tenant container, NFS, or an operator. Its existence is a deployment/environment fact not determinable from source.
Unvalidated fact: Whether the attacker can guess/know a high-value absolute target path; the DB default ./filebrowser.db is well-known but its absolute location is deployment-specific.
github.com/spf13/afero@v1.15.0 basepath.go:53-65 — RealPath does filepath.Clean + strings.HasPrefix and NO EvalSymlinks; Open/OpenFile/Stat (basepath.go:108-159) delegate the prefixed path to OsFs, so the OS follows an in-base symlink to any target
files/file.go:109-166 — stat() detects a symlink via LstatIfPossible then follows it with opts.Fs.Stat; files/file.go:170-202 Checksum opens+io.Copy-reads the followed target
http/raw.go:212-224 — rawFileHandler file.Fs.Open(file.Path) -> http.ServeContent streams the target for any file type/size
http/resource.go:297-315 writeFile afs.OpenFile(dst,O_RDWR|O_CREATE|O_TRUNC); fileutils/file.go:50 CopyFile OpenFile(dest,O_RDWR|O_CREATE|O_TRUNC); http/tus_handlers.go:67-87 OpenFile(O_CREATE|O_WRONLY[|O_TRUNC]) — none pass O_NOFOLLOW or Lstat-guard
http/data.go:36-45 — Check evaluates only the virtual path string; it never resolves or rejects the real symlink target
grep across the workspace: no os.Symlink/os.Link/SymlinkIfPossible in app code, and no unarchive/untar/unzip on upload (only archiver.Archive for download at raw.go:205) — the app cannot create the required symlink
Proven fact: afero BasePathFs enforces confinement by textual prefix only and performs no symlink resolution, so a symlink inside a user's scope pointing outside it is followed by the OS on read and write; textual '..' escapes remain blocked.
Proven fact: Every read sink (raw stream, checksum, stat, listing) and every write sink (PUT/POST-override, copy, rename, tus) opens the target without O_NOFOLLOW/EvalSymlinks, yielding out-of-scope read or O_TRUNC overwrite.
Proven fact: The bbolt DB (./filebrowser.db) holding settings.Key and bcrypt hashes sits outside all user roots; a followed read symlink discloses it, enabling forged HS256 admin JWTs.
Proven fact: FileBrowser creates no symlinks and performs no archive extraction, so the vulnerable primitive is a genuine confinement weakness in the resolution layer independent of any app-side link creation.
Unvalidated fact: Whether a symlink pointing outside a user's scope actually exists inside that scope in a given deployment. FileBrowser cannot create one (no os.Symlink/os.Link and no symlink-preserving archive extraction on any code path), so the link must be introduced out-of-band — shared/bind mount, co-tenant container, NFS, or an operator. Its existence is a deployment/environment fact not determinable from source.
files/file.go:112-151 stat() detects a symlink via Lstat then follows it with Fs.Stat
files/file.go:419 readListing follows entry symlinks via i.Fs.Stat
http/raw.go:213-222 rawFileHandler streams file.Fs.Open(file.Path) via ServeContent for any file type (no type/size gate)
files/file.go:179-200 Checksum opens and io.Copy-reads any followed file
http/resource.go:304 writeFile OpenFile(dst, O_RDWR|O_CREATE|O_TRUNC); fileutils/file.go:50 CopyFile OpenFile O_CREATE|O_TRUNC; http/tus_handlers.go:67,85 O_CREATE|O_TRUNC — grep confirms NO O_NOFOLLOW/EvalSymlinks/Lstat guard on any write path
http/data.go:36-45 Check evaluates only the virtual path string, so the symlink target is never rules-checked
users/users.go:95-96 BasePathFs root = Root+Scope; cmd/root.go:83 DB default ./filebrowser.db lies outside it
grep: no os.Symlink/os.Link creation and no archive extraction in-app (files/utils.go:58 is only a mode-bit test); precondition is an externally-introduced symlink