Recursive out-of-scope deletion via symlinked path components in the delete/move sinks
high Possibly Valid medium confidence
Status
Possibly Valid
Confirmed distinct-sink extension of the validated symlink-confinement weakness (DOK-100020, itself extending DOK-100003) to the delete family. The full path is code-proven: BasePathFs.RemoveAll/Remove do textual-only RealPath resolution (afero@v1.15.0 basepath.go:125-137, RealPath:53-65) then delegate to OsFs (users/users.go:96), and both resourceDeleteHandler (resource.go:115) and tusDeleteHandler (tus_handlers.go:264) reach RemoveAll behind only a Perm.Delete gate; the dedup stage explicitly kept this separate from DOK-100020 (which enumerates only read/write sinks). Rated possibly_valid rather than fully_valid solely because the triggering symlink must be introduced out-of-band (FileBrowser creates none), a deployment precondition not determinable from source — identical rationale to the parent finding. Severity high: out-of-scope recursive deletion is irreversible and can destroy the auth DB and arbitrary host files the process can reach.
Repository / Component
Plain-English Description
When a user deletes a file or folder, the app trusts the path text but does not check whether any folder along the way is a shortcut that points outside the user's allowed area. If such a shortcut exists, the delete can wipe out unrelated files elsewhere on the server, including the app's own login database.
Description of the Underlying Issue
FileBrowser confines each user with afero's BasePathFs (users/users.go:96 wrapping OsFs). BasePathFs.RealPath (afero@v1.15.0 basepath.go:53-65) performs only textual confinement — filepath.Clean + filepath.Join + a strings.HasPrefix check — and never calls filepath.EvalSymlinks. RemoveAll (basepath.go:125-130) and Remove (basepath.go:132-137) resolve via RealPath and then delegate directly to OsFs, i.e. os.RemoveAll/os.Remove on the real filesystem. Because os.RemoveAll follows symlinks encountered in intermediate directory components during traversal, an intermediate directory symlink placed inside the scope but pointing outside it causes the recursive removal to escape the scope root. This is a distinct sink from the read/write open-based sinks enumerated in DOK-100020; the dedup stage kept it separate.
Potential Attack
With a Perm.Delete account and an out-of-band intermediate directory symlink inside the scope (e.g. /scope/link -> /etc), the attacker issues DELETE /api/resources/link/somedir (resourceDeleteHandler, resource.go:115) or the equivalent tus delete route (tus_handlers.go:264). The gate is only `r.URL.Path == "/" || !d.user.Perm.Delete`, so the request proceeds and os.RemoveAll resolves through the symlink, deleting /etc/somedir out of scope. The same escape is reachable via rename through the MoveFile EXDEV fallback afs.Remove/afs.RemoveAll (fileutils/file.go:23,26), gated by Perm.Rename.
Outcomes of Potential Attack
Irreversible recursive deletion of arbitrary host files and subtrees reachable by the FileBrowser process, outside the user's scope. This can destroy the bbolt auth database, application config, or other host data, causing data loss and denial of service.
Affected Scope
Delete-family sinks: resourceDeleteHandler (http/resource.go:115) and tusDeleteHandler (http/tus_handlers.go:264), both calling d.user.Fs.RemoveAll(r.URL.Path); plus the fileutils MoveFile EXDEV fallback afs.Remove/afs.RemoveAll (fileutils/file.go:23,26). Gated only by Perm.Delete (Perm.Rename for the MoveFile fallback). Impact: recursive out-of-scope deletion of arbitrary host files/subtrees reachable by the process, including the bbolt auth DB.
Suggested Fix (plain english)
Before deleting, resolve the real target of the path (following any shortcuts) and refuse the operation if it lands outside the user's allowed folder.
Suggested Fix (detailed)
Apply the same remediation as DOK-100020: resolve real targets with filepath.EvalSymlinks and reject any that escape the scope root before calling RemoveAll/Remove, or Lstat each path component and refuse to delete through symlinked components, or wrap the user FS in a symlink-restricted afero layer. Apply the identical guard in the MoveFile EXDEV fallback (afs.Remove/afs.RemoveAll). Keep the bbolt DB and JWT signing key outside any user-reachable root. Verify by attempting a delete through an in-scope symlink to a benign out-of-scope path and confirming rejection. Same fix as DOK-100020: resolve real targets with filepath.EvalSymlinks and reject any that escape the scope root before RemoveAll/Remove, or Lstat-and-refuse to delete through symlinked path components, or wrap the user FS in a symlink-restricted afero layer. Keep the bbolt DB and JWT signing key outside any user-reachable root.
Validation
Confirmed distinct-sink extension of the validated symlink-confinement weakness (DOK-100020, itself extending DOK-100003) to the delete family. The full path is code-proven: BasePathFs.RemoveAll/Remove do textual-only RealPath resolution (afero@v1.15.0 basepath.go:125-137, RealPath:53-65) then delegate to OsFs (users/users.go:96), and both resourceDeleteHandler (resource.go:115) and tusDeleteHandler (tus_handlers.go:264) reach RemoveAll behind only a Perm.Delete gate; the dedup stage explicitly kept this separate from DOK-100020 (which enumerates only read/write sinks). Rated possibly_valid rather than fully_valid solely because the triggering symlink must be introduced out-of-band (FileBrowser creates none), a deployment precondition not determinable from source — identical rationale to the parent finding. Severity high: out-of-scope recursive deletion is irreversible and can destroy the auth DB and arbitrary host files the process can reach.
Full Evidence
afero@v1.15.0/basepath.go:125-130 RemoveAll and :132-137 Remove resolve via RealPath only (basepath.go:53-65: filepath.Clean + filepath.Join + strings.HasPrefix, no EvalSymlinks) then delegate to source.RemoveAll/Remove
users/users.go:96 u.Fs = afero.NewBasePathFs(afero.NewOsFs(), scope) — source is OsFs, so RemoveAll/Remove reach os.RemoveAll/os.Remove on the real filesystem
http/resource.go:85-116 resourceDeleteHandler gate is only `r.URL.Path == "/" || !d.user.Perm.Delete`, then d.user.Fs.RemoveAll(r.URL.Path) inside RunHook (route DELETE /api/resources)
http/tus_handlers.go:241-264 tusDeleteHandler same gate then d.user.Fs.RemoveAll(r.URL.Path)
An intermediate directory symlink (e.g. /scope/link -> /etc, request path /scope/link/subtree) is resolved by the kernel during os.RemoveAll traversal, so the recursive removal lands outside the scope root; a final-component symlink would only unlink the link itself (correctly relied on by the candidate)
Proven fact: BasePathFs performs no symlink resolution and blocks only textual '..' via the HasPrefix check, so a symlink placed inside a user's scope but pointing outside it is followed by the OS on delete (basepath.go RemoveAll/Remove -> OsFs)
Proven fact: Both the resource delete handler and the tus delete handler reach RemoveAll with a path that is only Perm.Delete-gated and only textually confined; this delete sink is distinct from the read/write sinks covered by DOK-100020, and the dedup stage kept it separate
Proven fact: The MoveFile EXDEV fallback (fileutils/file.go:23,26) issues afs.Remove(dst)/afs.RemoveAll(src) on the same unconfined FS, so the delete escape is also reachable via rename
Unvalidated fact: Whether a symlink pointing outside the user's scope actually exists inside that scope in a given deployment. FileBrowser creates no symlinks (no os.Symlink, no symlink-preserving archive extraction; the in-app-symlink-creation concern was dismissed), so the link must be introduced out-of-band — shared/bind mount, co-tenant container, NFS, or an operator. This environmental precondition is not determinable from source.
Unvalidated fact: Whether the attacker knows the absolute path of a high-value out-of-scope target (the DB default ./filebrowser.db is well-known but its absolute location is deployment-specific).
users/users.go:96 u.Fs = afero.NewBasePathFs(afero.NewOsFs(), scope) — source is OsFs, so RemoveAll/Remove reach os.RemoveAll/os.Remove
http/resource.go:114-116 resourceDeleteHandler runs d.user.Fs.RemoveAll(r.URL.Path) inside RunHook after only the `r.URL.Path == "/" || !d.user.Perm.Delete` gate
http/tus_handlers.go:241-264 tusDeleteHandler applies the same gate then d.user.Fs.RemoveAll(r.URL.Path)
An intermediate directory symlink (/scope/link -> /etc, request path /scope/link/subtree) is resolved by the kernel during os.RemoveAll traversal, so the recursive removal lands outside the scope root; a final-component symlink would only unlink the link itself
Proven fact: BasePathFs performs no symlink resolution and blocks only textual '..' via the HasPrefix check, so a symlink inside a user's scope pointing outside it is followed by the OS on delete
Proven fact: Both the resource delete handler and the tus delete handler reach RemoveAll with a path that is only Perm.Delete-gated and only textually confined; this delete sink is distinct from the read/write sinks covered by DOK-100020
Unvalidated fact: Whether a symlink pointing outside the user's scope actually exists inside that scope in a given deployment. FileBrowser creates no symlinks, so the link must be introduced out-of-band (shared/bind mount, co-tenant container, NFS, or an operator) — not determinable from source.
github.com/spf13/afero@v1.15.0/basepath.go:125-130 RemoveAll and :132-137 Remove call only RealPath() (basepath.go:53-65: filepath.Clean + strings.HasPrefix, no EvalSymlinks) then delegate to source OsFs.RemoveAll/Remove — no O_NOFOLLOW
http/resource.go:85-116 resourceDeleteHandler: gate is only 'r.URL.Path == "/" || !d.user.Perm.Delete', then RunHook -> d.user.Fs.RemoveAll(r.URL.Path); route http/http.go:62 DELETE /api/resources
http/tus_handlers.go:241-264 tusDeleteHandler -> d.user.Fs.RemoveAll(r.URL.Path) (Perm.Delete, route http/http.go:70)
fileutils/file.go:16-29 MoveFile fallback -> afs.Remove(dst)/afs.RemoveAll(src) on the same unconfined Fs
DOK-100020 affected_scope enumerates only read (raw/checksum/stat/listing) and write (upload/save/copy/rename/tus) and its proven_facts describe only 'out-of-scope read or O_TRUNC overwrite' — the unlink/RemoveAll sink and directory-tree removal are never covered
No existing candidate/dismissed record covers delete-via-symlink (the override-rollback RemoveAll and upload-cache os.Remove findings are Perm-bypass/absolute-path issues, not symlink confinement escapes)