Out-of-scope file read/exfiltration via symlinked path components in the rename/move sink
high Possibly Valid medium confidence
Status
Possibly Valid
Confirmed distinct-sink extension of the validated symlink-confinement weakness (DOK-100020/DOK-100003) to the rename/move family. Code-proven: BasePathFs.Rename does textual-only RealPath resolution (afero@v1.15.0 basepath.go:115-123) then OsFs.Rename, which follows intermediate-component symlinks; patchAction rename (resource.go:374) reaches it behind only Perm.Rename and a textual d.Check, and the EXDEV Copy+RemoveAll fallback (file.go:21-28) preserves the escape. DOK-100020 mentions 'rename' only in prose and models a final-file symlink + O_TRUNC overwrite, not the rename-through-intermediate-directory-symlink move primitive (read-into-scope / relocate-out-of-scope); the dedup stage kept it separate. 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 to the parent finding. Severity high: moving an out-of-scope file into scope yields arbitrary host-file disclosure (auth DB → forged admin JWT chain per DOK-100020).
Repository / Component
Plain-English Description
When a user renames or moves a file, the app checks the path as plain text but doesn't verify that folders along the way are real folders rather than shortcuts pointing elsewhere. An attacker can use such a shortcut to pull a file from outside their allowed area into it and then download it, or push their files out.
Description of the Underlying Issue
BasePathFs.Rename (afero@v1.15.0 basepath.go:115-123) resolves both operands via RealPath, which is textual-only (basepath.go:53-65, no EvalSymlinks), then delegates to OsFs.Rename (os.Rename). rename(2) resolves and follows symlinks in intermediate path components (never the final component), so an in-scope intermediate directory symlink pointing out of scope escapes the scope root while RealPath's HasPrefix check blocks only textual '..'. The rename action (patchAction action=="rename", resource.go:349-374) reaches fileutils.MoveFile (file.go:16-29) behind only Perm.Rename and the textual d.Check gate. This Rename/move primitive is distinct from the open-based read/write sinks in DOK-100020 (which models a final-file symlink + O_TRUNC overwrite); the dedup stage kept it separate.
Potential Attack
With a Perm.Rename account and an out-of-band intermediate directory symlink /scope/link -> /etc, the attacker issues PATCH /api/resources/link/shadow?action=rename&destination=/shadow_copy. os.Rename resolves the symlink and moves /etc/shadow to /scope/shadow_copy, now inside scope and downloadable. checkParent (resource.go:265-277) is satisfied because dst is not a parent of src, and d.Check (resource.go:221) validates only the textual in-scope path. The reverse direction relocates scope content out of scope. The EXDEV Copy+RemoveAll fallback (file.go:21-28, CopyFile via afs.Open which follows symlinks) preserves the escape even across mounts.
Outcomes of Potential Attack
Arbitrary host-file disclosure: an out-of-scope file (e.g. /etc/shadow, the bbolt auth DB) is moved into the user's scope and downloaded. Reading the auth DB enables the forged-admin-JWT chain described in DOK-100020. The reverse operation exfiltrates or relocates in-scope content to arbitrary out-of-scope locations, enabling data loss or planting.
Affected Scope
Rename/move sink: patchAction action=="rename" (http/resource.go:349-374) -> fileutils.MoveFile (fileutils/file.go:16-29) -> afs.Rename, plus the EXDEV Copy+RemoveAll fallback. Gated only by Perm.Rename (route PATCH /api/resources). Impact: move an out-of-scope file into scope (then download → arbitrary host-file read, e.g. auth DB / /etc secrets) or relocate scope content out of scope.
Suggested Fix (plain english)
Before renaming or moving, resolve the real location of both the source and destination (following shortcuts) and refuse if either lands outside the user's allowed folder.
Suggested Fix (detailed)
As in DOK-100020: EvalSymlinks-resolve and re-confine both rename operands before afs.Rename, or refuse renames whose path components include symlinks escaping the scope. Apply the identical guard to the CopyFile source open and RemoveAll in the EXDEV fallback (file.go:21-28). Verify by attempting a rename through an in-scope symlink to a benign out-of-scope path and confirming rejection on both the same-mount and cross-mount code paths. Same fix as DOK-100020: EvalSymlinks-resolve and re-confine both rename operands before afs.Rename, or refuse renames whose path components include symlinks escaping the scope, and apply the same guard in the CopyFile/RemoveAll EXDEV fallback.
Validation
Confirmed distinct-sink extension of the validated symlink-confinement weakness (DOK-100020/DOK-100003) to the rename/move family. Code-proven: BasePathFs.Rename does textual-only RealPath resolution (afero@v1.15.0 basepath.go:115-123) then OsFs.Rename, which follows intermediate-component symlinks; patchAction rename (resource.go:374) reaches it behind only Perm.Rename and a textual d.Check, and the EXDEV Copy+RemoveAll fallback (file.go:21-28) preserves the escape. DOK-100020 mentions 'rename' only in prose and models a final-file symlink + O_TRUNC overwrite, not the rename-through-intermediate-directory-symlink move primitive (read-into-scope / relocate-out-of-scope); the dedup stage kept it separate. 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 to the parent finding. Severity high: moving an out-of-scope file into scope yields arbitrary host-file disclosure (auth DB → forged admin JWT chain per DOK-100020).
Full Evidence
afero@v1.15.0/basepath.go:115-123 Rename resolves oldname/newname via RealPath (textual prefix only, no EvalSymlinks) then source.Rename; with OsFs source (users/users.go:96) this is os.Rename
rename(2) resolves and follows symlinks in intermediate path components (never the final component), so an intermediate directory symlink escapes the scope root while RealPath's HasPrefix check only blocks textual '..'
http/resource.go:349-374 patchAction action=="rename" gate is only `!d.user.Perm.Rename`, then fileutils.MoveFile(d.user.Fs, src, dst)
fileutils/file.go:16-29 MoveFile calls afs.Rename first; on EXDEV it falls back to Copy (CopyFile afs.Open src, following symlinks) + afs.RemoveAll(src), so the escape is mount-independent
http/resource.go:221 the PATCH handler's d.Check(src)/d.Check(dst) validates only the textual in-scope path, which an in-scope intermediate symlink passes; checkParent (resource.go:265-277) is satisfied since dst is not a parent of src
Proven fact: BasePathFs.Rename does no symlink resolution (basepath.go:115-123 + RealPath:53-65) and delegates to OsFs.Rename, so an in-scope intermediate directory symlink pointing out of scope is followed by rename(2)
Proven fact: The rename action is reachable behind only Perm.Rename and the textual d.Check gate; this Rename sink is distinct from the open-based read/write sinks in DOK-100020 and was kept separate by the dedup stage
Proven fact: The EXDEV fallback (Copy + RemoveAll, fileutils/file.go:21-28) also follows symlinks, so the confinement escape holds whether or not src and dst share a mount
Unvalidated fact: Whether an out-of-scope-pointing symlink exists inside the user's scope in a given deployment; FileBrowser creates none, so it must be introduced out-of-band (shared mount, co-tenant container, NFS, or operator) — not determinable from source.
Unvalidated fact: Whether the attacker knows the absolute path of the out-of-scope target to move into scope (deployment-specific).
rename(2) resolves and follows symlinks in intermediate path components (never the final), so an intermediate directory symlink escapes the scope root while RealPath's HasPrefix check only blocks textual '..'
fileutils/file.go:16-29 MoveFile calls afs.Rename first; on EXDEV it falls back to Copy (CopyFile afs.Open src, following symlinks) + afs.RemoveAll(src)
http/resource.go:221 the PATCH handler's d.Check(src)/d.Check(dst) validates only the textual in-scope path; checkParent (resource.go:265-277) is satisfied since dst is not a parent of src
github.com/spf13/afero@v1.15.0/basepath.go:115-123 Rename resolves oldname/newname via RealPath (textual prefix only) then OsFs.Rename; rename(2) follows intermediate path symlinks (never the final component), no EvalSymlinks
http/resource.go:349-374 patchAction action=='rename' gate is only !d.user.Perm.Rename, then fileutils.MoveFile -> fileutils/file.go:17 afs.Rename(src,dst); route http/http.go:65 PATCH /api/resources
fileutils/file.go:16-29 MoveFile EXDEV fallback Copy+RemoveAll also follows symlinks (CopyFile afs.Open src, afs.RemoveAll), so the confinement escape is mount-independent
DOK-100020 lists 'rename' only in prose scope but never enumerates the afs.Rename sink and models a final-file symlink + O_TRUNC, which does not capture the rename-through-intermediate-directory-symlink escape